use crank_core::{HttpMethod, Target}; use crank_mapping::MappingSet; use crank_registry::RegistryOperation; use crank_schema::{Schema, SchemaKind}; pub fn import_guidance_warnings(operation: &RegistryOperation) -> Vec { let mut warnings = Vec::new(); if is_generic_tool_name(&operation.name) { warnings.push( "Имя инструмента слишком общее. Используйте конкретное действие и объект, например get_customer_by_email.".to_owned(), ); } if operation .tool_description .description .trim() .chars() .count() < 40 { warnings.push( "Описание инструмента короткое. Добавьте, когда его вызывать, какие входные данные нужны и что означает успешный ответ.".to_owned(), ); } if schema_has_multi_action_field(&operation.input_schema) { warnings.push( "Входная схема похожа на endpoint с несколькими действиями. Если параметр action/mode/type выбирает разные сценарии, лучше разделить это на несколько MCP-инструментов.".to_owned(), ); } if output_mapping_returns_broad_response(&operation.output_mapping) { warnings.push( "Настройка результата может отдавать агенту слишком широкий ответ. Выберите только поля, которые нужны модели для следующего шага.".to_owned(), ); } match &operation.target { Target::Rest(target) => { if operation.category == "general" { warnings.push( "Операция импортирована в общей категории. Перед привязкой к агенту сгруппируйте похожие endpoint-ы по конкретной задаче.".to_owned(), ); } if target.method != HttpMethod::Get && operation.execution_config.idempotency.is_none() { warnings.push( "Операция меняет состояние и не задает ключ идемпотентности. Для POST, PUT и PATCH добавьте стабильный ключ, если повторный вызов может создать дубль.".to_owned(), ); } if target.method == HttpMethod::Delete { warnings.push( "DELETE будет выполняться через двухшаговое подтверждение: первый вызов выдаст токен, второй выполнит запрос.".to_owned(), ); } } } warnings } fn is_generic_tool_name(name: &str) -> bool { matches!( name.trim().to_ascii_lowercase().as_str(), "call_api" | "execute" | "execute_request" | "manage" | "manage_resource" | "request" ) } fn schema_has_multi_action_field(schema: &Schema) -> bool { if !matches!(schema.kind, SchemaKind::Object) { return false; } ["action", "mode", "operation", "type"] .iter() .any(|field_name| schema.fields.contains_key(*field_name)) } fn output_mapping_returns_broad_response(mapping: &MappingSet) -> bool { mapping.rules.iter().any(|rule| { let source = rule.source.trim(); let target = rule.target.trim(); matches!(source, "$.response" | "$.response.body" | "$.response.data") || matches!(target, "$.output" | "$") }) } #[cfg(test)] mod tests { use std::collections::BTreeMap; use crank_core::{ ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel, OperationStatus, Protocol, RestTarget, Target, ToolDescription, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::RegistryOperation; use crank_schema::{Schema, SchemaKind}; use time::OffsetDateTime; use super::import_guidance_warnings; #[test] fn flags_generic_multi_action_delete() { let operation = imported_delete_operation(); let warnings = import_guidance_warnings(&operation); let joined = warnings.join("\n"); assert!(joined.contains("Имя инструмента слишком общее")); assert!(joined.contains("несколькими действиями")); assert!(joined.contains("Настройка результата")); assert!(joined.contains("ключ идемпотентности")); assert!(joined.contains("DELETE будет выполняться")); assert!(joined.contains("сгруппируйте похожие endpoint-ы")); } fn imported_delete_operation() -> RegistryOperation { Operation { id: OperationId::new("op_imported_delete"), name: "call_api".to_owned(), display_name: "Call API".to_owned(), category: "general".to_owned(), protocol: Protocol::Rest, security_level: OperationSecurityLevel::Standard, status: OperationStatus::Draft, version: 1, target: Target::Rest(RestTarget { base_url: "http://example.invalid".to_owned(), method: HttpMethod::Delete, path_template: "/items/{id}".to_owned(), static_headers: BTreeMap::new(), }), input_schema: Schema { kind: SchemaKind::Object, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::from([("action".to_owned(), string_schema())]), items: None, enum_values: Vec::new(), variants: Vec::new(), }, output_schema: Schema { kind: SchemaKind::Object, description: None, required: false, nullable: false, default_value: None, fields: BTreeMap::new(), items: None, enum_values: Vec::new(), variants: Vec::new(), }, input_mapping: MappingSet { rules: Vec::new() }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.body".to_owned(), target: "$.output".to_owned(), required: false, default_value: None, transform: None, condition: None, notes: None, }], }, execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, response_cache: None, idempotency: None, safety: None, approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, tool_description: ToolDescription { title: "Call API".to_owned(), description: "Calls API".to_owned(), tags: Vec::new(), examples: Vec::new(), }, samples: None, generated_draft: None, config_export: None, wizard_state: None, created_at: OffsetDateTime::UNIX_EPOCH, updated_at: OffsetDateTime::UNIX_EPOCH, published_at: None, } } fn string_schema() -> Schema { Schema { kind: SchemaKind::String, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::new(), items: None, enum_values: Vec::new(), variants: Vec::new(), } } }