624 lines
20 KiB
Rust
624 lines
20 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use time::OffsetDateTime;
|
|
|
|
use crate::{
|
|
edition::OperationSecurityLevel,
|
|
ids::{AuthProfileId, OperationId, SampleId},
|
|
protocol::{ExportMode, HttpMethod, Protocol},
|
|
tool_quality::ToolQualityFinding,
|
|
};
|
|
|
|
fn default_operation_category() -> String {
|
|
"general".to_owned()
|
|
}
|
|
|
|
fn default_operation_security_level() -> OperationSecurityLevel {
|
|
OperationSecurityLevel::Standard
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperationStatus {
|
|
Draft,
|
|
Testing,
|
|
Published,
|
|
Archived,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperationAvailability {
|
|
Active,
|
|
Archived,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperationVersionState {
|
|
Draft,
|
|
Published,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum OperationLifecycleAction {
|
|
SaveDraft,
|
|
Publish,
|
|
Archive,
|
|
Delete,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
|
pub enum OperationLifecycleError {
|
|
#[error("operation is archived")]
|
|
Archived,
|
|
#[error("operation lifecycle transition is invalid")]
|
|
InvalidTransition,
|
|
#[error("operation deletion would destroy durable history")]
|
|
DurableHistory,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct OperationLifecycle {
|
|
pub availability: OperationAvailability,
|
|
pub current: OperationVersionState,
|
|
pub ever_published: bool,
|
|
pub has_durable_references: bool,
|
|
}
|
|
|
|
impl OperationLifecycle {
|
|
pub fn validate(self, action: OperationLifecycleAction) -> Result<(), OperationLifecycleError> {
|
|
if self.availability == OperationAvailability::Archived {
|
|
return match action {
|
|
OperationLifecycleAction::Archive => Ok(()),
|
|
_ => Err(OperationLifecycleError::Archived),
|
|
};
|
|
}
|
|
match action {
|
|
OperationLifecycleAction::SaveDraft => Ok(()),
|
|
OperationLifecycleAction::Publish if self.current == OperationVersionState::Draft => {
|
|
Ok(())
|
|
}
|
|
OperationLifecycleAction::Archive => Ok(()),
|
|
OperationLifecycleAction::Delete
|
|
if !self.ever_published && !self.has_durable_references =>
|
|
{
|
|
Ok(())
|
|
}
|
|
OperationLifecycleAction::Delete => Err(OperationLifecycleError::DurableHistory),
|
|
OperationLifecycleAction::Publish => Err(OperationLifecycleError::InvalidTransition),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RestTarget {
|
|
pub base_url: String,
|
|
pub method: HttpMethod,
|
|
pub path_template: String,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub static_headers: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum Target {
|
|
Rest(RestTarget),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RetryPolicy {
|
|
pub max_attempts: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ResponseCachePolicy {
|
|
pub ttl_ms: u64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum IdempotencyMode {
|
|
Disabled,
|
|
Optional,
|
|
Required,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct IdempotencyPolicy {
|
|
pub mode: IdempotencyMode,
|
|
pub ttl_ms: u64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub input_field: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub header_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperationSafetyClass {
|
|
#[default]
|
|
Read,
|
|
Write,
|
|
Destructive,
|
|
ExternalMessage,
|
|
FinancialOrIrreversible,
|
|
}
|
|
|
|
impl OperationSafetyClass {
|
|
pub fn requires_confirmation(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Destructive | Self::ExternalMessage | Self::FinancialOrIrreversible
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ConfirmationPolicy {
|
|
pub ttl_ms: u64,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OperationSafetyPolicy {
|
|
pub class: OperationSafetyClass,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub confirmation: Option<ConfirmationPolicy>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperationApprovalRiskLevel {
|
|
#[default]
|
|
Normal,
|
|
Dangerous,
|
|
Financial,
|
|
Irreversible,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperationApprovalPayloadPreviewMode {
|
|
#[default]
|
|
Summary,
|
|
MaskedJson,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum OperationApprovalMode {
|
|
#[default]
|
|
Custom,
|
|
Elicitation,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OperationApprovalPolicy {
|
|
pub required: bool,
|
|
#[serde(default)]
|
|
pub mode: OperationApprovalMode,
|
|
pub risk_level: OperationApprovalRiskLevel,
|
|
pub ttl_seconds: u32,
|
|
pub show_payload_preview: bool,
|
|
pub payload_preview_mode: OperationApprovalPayloadPreviewMode,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub elicitation_message: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ExecutionConfig {
|
|
pub timeout_ms: u64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub retry_policy: Option<RetryPolicy>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub response_cache: Option<ResponseCachePolicy>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub idempotency: Option<IdempotencyPolicy>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub safety: Option<OperationSafetyPolicy>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub approval_policy: Option<OperationApprovalPolicy>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub auth_profile_ref: Option<AuthProfileId>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub headers: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ToolExample {
|
|
pub input: Value,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ToolDescription {
|
|
pub title: String,
|
|
pub description: String,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub tags: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub examples: Vec<ToolExample>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub struct Samples {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub input_json_sample_ref: Option<SampleId>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub output_json_sample_ref: Option<SampleId>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum GeneratedDraftStatus {
|
|
None,
|
|
Available,
|
|
Stale,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct GeneratedDraft {
|
|
pub status: GeneratedDraftStatus,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub source_types: Vec<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub generated_at: Option<String>,
|
|
pub input_schema_generated: bool,
|
|
pub output_schema_generated: bool,
|
|
pub input_mapping_generated: bool,
|
|
pub output_mapping_generated: bool,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ConfigExport {
|
|
pub format_version: String,
|
|
pub export_mode: ExportMode,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub struct WizardState {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub input_sample: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub output_sample: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub test_input: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub import_findings: Vec<ToolQualityFinding>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Operation<TSchema, TMapping> {
|
|
pub id: OperationId,
|
|
pub name: String,
|
|
pub display_name: String,
|
|
#[serde(default = "default_operation_category")]
|
|
pub category: String,
|
|
pub protocol: Protocol,
|
|
#[serde(default = "default_operation_security_level")]
|
|
pub security_level: OperationSecurityLevel,
|
|
pub status: OperationStatus,
|
|
pub version: u32,
|
|
pub target: Target,
|
|
pub input_schema: TSchema,
|
|
pub output_schema: TSchema,
|
|
pub input_mapping: TMapping,
|
|
pub output_mapping: TMapping,
|
|
pub execution_config: ExecutionConfig,
|
|
pub tool_description: ToolDescription,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub samples: Option<Samples>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub generated_draft: Option<GeneratedDraft>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub config_export: Option<ConfigExport>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub wizard_state: Option<WizardState>,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub created_at: OffsetDateTime,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub updated_at: OffsetDateTime,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
#[serde(with = "time::serde::rfc3339::option")]
|
|
pub published_at: Option<OffsetDateTime>,
|
|
}
|
|
|
|
impl<TSchema, TMapping> Operation<TSchema, TMapping> {
|
|
pub fn tool_name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
pub fn is_draft(&self) -> bool {
|
|
self.status == OperationStatus::Draft
|
|
}
|
|
|
|
pub fn is_published(&self) -> bool {
|
|
self.status == OperationStatus::Published
|
|
}
|
|
|
|
pub fn protocol(&self) -> Protocol {
|
|
self.protocol
|
|
}
|
|
|
|
pub fn auth_profile_ref(&self) -> Option<&AuthProfileId> {
|
|
self.execution_config.auth_profile_ref.as_ref()
|
|
}
|
|
}
|
|
|
|
impl<TSchema: PartialEq, TMapping: PartialEq> Operation<TSchema, TMapping> {
|
|
pub fn portable_semantically_eq(&self, other: &Self) -> bool {
|
|
self.name == other.name
|
|
&& self.display_name == other.display_name
|
|
&& self.category == other.category
|
|
&& self.protocol == other.protocol
|
|
&& self.security_level == other.security_level
|
|
&& self.target == other.target
|
|
&& self.input_schema == other.input_schema
|
|
&& self.output_schema == other.output_schema
|
|
&& self.input_mapping == other.input_mapping
|
|
&& self.output_mapping == other.output_mapping
|
|
&& self.execution_config == other.execution_config
|
|
&& self.tool_description == other.tool_description
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::collections::BTreeMap;
|
|
|
|
use serde_json::json;
|
|
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|
|
|
use crate::{
|
|
auth::{AuthConfig, AuthProfile, BearerAuthConfig},
|
|
edition::OperationSecurityLevel,
|
|
ids::{AuthProfileId, OperationId},
|
|
operation::{
|
|
ConfigExport, ExecutionConfig, Operation, OperationAvailability, OperationLifecycle,
|
|
OperationLifecycleAction, OperationLifecycleError, OperationStatus,
|
|
OperationVersionState, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
|
},
|
|
protocol::{AuthKind, ExportMode, HttpMethod, Protocol},
|
|
};
|
|
|
|
fn timestamp(value: &str) -> OffsetDateTime {
|
|
OffsetDateTime::parse(value, &Rfc3339).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn rest_target_serializes_with_kind_tag() {
|
|
let target = Target::Rest(RestTarget {
|
|
base_url: "https://api.example.com".to_owned(),
|
|
method: HttpMethod::Post,
|
|
path_template: "/v1/leads".to_owned(),
|
|
static_headers: BTreeMap::new(),
|
|
});
|
|
|
|
let value = serde_json::to_value(target).unwrap();
|
|
|
|
assert_eq!(value["kind"], "rest");
|
|
assert_eq!(value["method"], "POST");
|
|
}
|
|
|
|
#[test]
|
|
fn operation_exposes_local_domain_helpers() {
|
|
let operation = test_operation(OperationStatus::Draft);
|
|
|
|
assert_eq!(operation.tool_name(), "crm_create_lead");
|
|
assert!(operation.is_draft());
|
|
assert!(!operation.is_published());
|
|
assert_eq!(operation.protocol(), Protocol::Rest);
|
|
assert_eq!(
|
|
operation.auth_profile_ref().map(|value| value.as_str()),
|
|
Some("auth_01")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lifecycle_rejects_publish_rewind_and_durable_delete() {
|
|
let published = OperationLifecycle {
|
|
availability: OperationAvailability::Active,
|
|
current: OperationVersionState::Published,
|
|
ever_published: true,
|
|
has_durable_references: true,
|
|
};
|
|
|
|
assert_eq!(
|
|
published.validate(OperationLifecycleAction::Publish),
|
|
Err(OperationLifecycleError::InvalidTransition)
|
|
);
|
|
assert_eq!(
|
|
published.validate(OperationLifecycleAction::Delete),
|
|
Err(OperationLifecycleError::DurableHistory)
|
|
);
|
|
assert_eq!(
|
|
published.validate(OperationLifecycleAction::SaveDraft),
|
|
Ok(())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_lifecycle_is_terminal_but_archive_retry_is_idempotent() {
|
|
let archived = OperationLifecycle {
|
|
availability: OperationAvailability::Archived,
|
|
current: OperationVersionState::Published,
|
|
ever_published: true,
|
|
has_durable_references: true,
|
|
};
|
|
|
|
assert_eq!(archived.validate(OperationLifecycleAction::Archive), Ok(()));
|
|
assert_eq!(
|
|
archived.validate(OperationLifecycleAction::SaveDraft),
|
|
Err(OperationLifecycleError::Archived)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn portable_semantic_equality_ignores_persistence_identity() {
|
|
let left = test_operation(OperationStatus::Draft);
|
|
let mut right = left.clone();
|
|
right.id = OperationId::new("op_other");
|
|
right.version = 9;
|
|
right.status = OperationStatus::Published;
|
|
right.updated_at = timestamp("2026-03-25T09:00:00Z");
|
|
right.published_at = Some(timestamp("2026-03-25T09:00:00Z"));
|
|
|
|
assert!(left.portable_semantically_eq(&right));
|
|
right.display_name = "Changed".to_owned();
|
|
assert!(!left.portable_semantically_eq(&right));
|
|
}
|
|
|
|
#[test]
|
|
fn auth_profile_serializes_secret_ids_without_secret_values() {
|
|
let profile = AuthProfile {
|
|
id: AuthProfileId::new("auth_01"),
|
|
workspace_id: crate::ids::WorkspaceId::new("ws_01"),
|
|
name: "crm-prod-bearer".to_owned(),
|
|
kind: AuthKind::Bearer,
|
|
config: AuthConfig::Bearer(BearerAuthConfig {
|
|
header_name: "Authorization".to_owned(),
|
|
secret_id: crate::ids::SecretId::new("secret_crm_prod_token"),
|
|
}),
|
|
created_at: timestamp("2026-03-25T08:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T08:00:00Z"),
|
|
};
|
|
|
|
let value = serde_json::to_value(profile).unwrap();
|
|
|
|
assert_eq!(value["kind"], "bearer");
|
|
assert_eq!(
|
|
value["config"]["bearer"]["secret_id"],
|
|
"secret_crm_prod_token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn operation_roundtrips_through_yaml() {
|
|
let operation = Operation {
|
|
status: OperationStatus::Published,
|
|
version: 3,
|
|
config_export: Some(ConfigExport {
|
|
format_version: "1".to_owned(),
|
|
export_mode: ExportMode::Portable,
|
|
}),
|
|
published_at: Some(timestamp("2026-03-25T08:15:00Z")),
|
|
..test_operation(OperationStatus::Draft)
|
|
};
|
|
|
|
let yaml = serde_yaml::to_string(&operation).unwrap();
|
|
let restored: Operation<serde_json::Value, serde_json::Value> =
|
|
serde_yaml::from_str(&yaml).unwrap();
|
|
|
|
assert!(yaml.contains("protocol: rest"));
|
|
assert!(yaml.contains("security_level: standard"));
|
|
assert!(yaml.contains("export_mode: portable"));
|
|
assert_eq!(restored, operation);
|
|
}
|
|
|
|
#[test]
|
|
fn operation_yaml_deserializes_without_optional_published_at() {
|
|
let yaml = r#"
|
|
id: op_01
|
|
name: crm_create_lead
|
|
display_name: Create Lead
|
|
category: sales
|
|
protocol: rest
|
|
security_level: standard
|
|
status: draft
|
|
version: 1
|
|
target:
|
|
kind: rest
|
|
base_url: https://api.example.com
|
|
method: POST
|
|
path_template: /v1/leads
|
|
static_headers: {}
|
|
input_schema:
|
|
type: object
|
|
output_schema:
|
|
type: object
|
|
input_mapping:
|
|
rules: []
|
|
output_mapping:
|
|
rules: []
|
|
execution_config:
|
|
timeout_ms: 10000
|
|
headers: {}
|
|
tool_description:
|
|
title: Create CRM lead
|
|
description: Creates a new lead.
|
|
tags: []
|
|
examples: []
|
|
created_at: 2026-03-25T08:00:00Z
|
|
updated_at: 2026-03-25T08:10:00Z
|
|
"#;
|
|
|
|
let restored: Operation<serde_json::Value, serde_json::Value> =
|
|
serde_yaml::from_str(yaml).unwrap();
|
|
|
|
assert_eq!(restored.security_level, OperationSecurityLevel::Standard);
|
|
assert_eq!(restored.published_at, None);
|
|
}
|
|
|
|
fn test_operation(status: OperationStatus) -> Operation<serde_json::Value, serde_json::Value> {
|
|
Operation {
|
|
id: OperationId::new("op_01"),
|
|
name: "crm_create_lead".to_owned(),
|
|
display_name: "Create Lead".to_owned(),
|
|
category: "sales".to_owned(),
|
|
protocol: Protocol::Rest,
|
|
security_level: OperationSecurityLevel::Standard,
|
|
status,
|
|
version: 1,
|
|
target: Target::Rest(RestTarget {
|
|
base_url: "https://api.example.com".to_owned(),
|
|
method: HttpMethod::Post,
|
|
path_template: "/v1/leads".to_owned(),
|
|
static_headers: BTreeMap::new(),
|
|
}),
|
|
input_schema: json!({"type":"object","fields":{"email":{"type":"string","required":true}}}),
|
|
output_schema: json!({"type":"object","fields":{"id":{"type":"string","required":true}}}),
|
|
input_mapping: json!({"rules":[{"source":"$.mcp.email","target":"$.request.body.email"}]}),
|
|
output_mapping: json!({"rules":[{"source":"$.response.body.id","target":"$.output.id"}]}),
|
|
execution_config: ExecutionConfig {
|
|
timeout_ms: 10_000,
|
|
retry_policy: None,
|
|
response_cache: None,
|
|
idempotency: None,
|
|
safety: None,
|
|
approval_policy: None,
|
|
auth_profile_ref: Some(AuthProfileId::new("auth_01")),
|
|
headers: BTreeMap::new(),
|
|
},
|
|
tool_description: ToolDescription {
|
|
title: "Create CRM lead".to_owned(),
|
|
description: "Creates a new lead.".to_owned(),
|
|
tags: vec!["crm".to_owned()],
|
|
examples: vec![ToolExample {
|
|
input: json!({"email":"user@example.com"}),
|
|
}],
|
|
},
|
|
samples: Some(Samples::default()),
|
|
generated_draft: None,
|
|
config_export: None,
|
|
wizard_state: None,
|
|
created_at: timestamp("2026-03-25T08:00:00Z"),
|
|
updated_at: timestamp("2026-03-25T08:10:00Z"),
|
|
published_at: None,
|
|
}
|
|
}
|
|
}
|