1use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::bridges::{Bridge, BridgeError, BridgeKind};
15
16#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
17pub struct McpTool {
18 pub name: String,
19 #[serde(skip_serializing_if = "Option::is_none", default)]
20 pub description: Option<String>,
21 #[serde(
22 rename = "inputSchema",
23 skip_serializing_if = "Option::is_none",
24 default
25 )]
26 pub input_schema: Option<Value>,
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
30pub struct McpToolList {
31 pub tools: Vec<McpTool>,
32}
33
34#[derive(Clone, Debug, Default)]
35pub struct McpImportOptions {
36 pub default_risk: Option<String>,
37 pub default_approval: Option<String>,
38 pub default_proof: Option<String>,
39 pub danger_tag_map: HashMap<String, Vec<String>>,
40 pub name_prefix: Option<String>,
41}
42
43#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
46pub struct McpAction {
47 pub name: String,
48 pub risk: String,
49 pub approval: String,
50 #[serde(skip_serializing_if = "Option::is_none", default)]
51 pub proof: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none", default)]
53 pub description: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none", default)]
55 pub parameters: Option<Value>,
56 #[serde(skip_serializing_if = "Option::is_none", default)]
57 pub danger_tags: Option<Vec<String>>,
58}
59
60fn is_valid_action_name(name: &str) -> bool {
64 let mut segments = 0usize;
65 for segment in name.split('.') {
66 let bytes = segment.as_bytes();
67 let Some((&first, rest)) = bytes.split_first() else {
68 return false; };
70 if !first.is_ascii_lowercase() {
71 return false;
72 }
73 if !rest
74 .iter()
75 .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
76 {
77 return false;
78 }
79 segments += 1;
80 }
81 segments >= 2
82}
83
84fn normalize_tool_name(name: &str, prefix: Option<&str>) -> String {
85 let mut scrubbed = String::with_capacity(name.len());
86 let mut prev_underscore = false;
87 for ch in name.chars() {
88 if ch.is_ascii_alphanumeric() {
89 scrubbed.push(ch.to_ascii_lowercase());
90 prev_underscore = false;
91 } else if !prev_underscore {
92 scrubbed.push('_');
93 prev_underscore = true;
94 }
95 }
96 let trimmed = scrubbed.trim_matches('_').to_string();
97 let with_prefix = match prefix {
98 Some(p) if !p.is_empty() => format!("{}.{}", p, trimmed),
99 _ => trimmed,
100 };
101 if with_prefix.contains('.') {
102 with_prefix
103 } else {
104 format!("mcp.{}", with_prefix)
105 }
106}
107
108pub fn mcp_to_contract_actions(
109 tool_list: &McpToolList,
110 opts: &McpImportOptions,
111) -> Result<Vec<McpAction>, BridgeError> {
112 let default_risk = opts
113 .default_risk
114 .clone()
115 .unwrap_or_else(|| "R2".to_string());
116 let default_approval = opts
117 .default_approval
118 .clone()
119 .unwrap_or_else(|| "conditional".to_string());
120 let mut out = Vec::with_capacity(tool_list.tools.len());
121 for tool in &tool_list.tools {
122 if tool.name.is_empty() {
123 return Err(BridgeError::InvalidInput("MCP tool missing a name".into()));
124 }
125 let action_name = normalize_tool_name(&tool.name, opts.name_prefix.as_deref());
126 if !is_valid_action_name(&action_name) {
127 return Err(BridgeError::InvalidInput(format!(
128 "MCP tool {} produced invalid action name {}",
129 tool.name, action_name
130 )));
131 }
132 let danger_tags = opts.danger_tag_map.get(&tool.name).cloned();
133 let action = McpAction {
134 name: action_name,
135 risk: default_risk.clone(),
136 approval: default_approval.clone(),
137 proof: opts.default_proof.clone(),
138 description: tool.description.clone(),
139 parameters: tool.input_schema.clone(),
140 danger_tags: danger_tags.filter(|t| !t.is_empty()),
141 };
142 out.push(action);
143 }
144 Ok(out)
145}
146
147pub fn contract_to_mcp_tools(actions: &[McpAction]) -> McpToolList {
148 let tools = actions
149 .iter()
150 .map(|action| {
151 let warning = match action.danger_tags.as_ref() {
152 Some(tags) if !tags.is_empty() => format!("⚠️ {}. ", tags.join(", ")),
153 _ => String::new(),
154 };
155 let description = format!(
156 "{}{}",
157 warning,
158 action.description.clone().unwrap_or_default()
159 )
160 .trim()
161 .to_string();
162 McpTool {
163 name: action.name.clone(),
164 description: if description.is_empty() {
165 None
166 } else {
167 Some(description)
168 },
169 input_schema: action.parameters.clone(),
170 }
171 })
172 .collect();
173 McpToolList { tools }
174}
175
176#[derive(Clone, Debug, Default)]
177pub struct McpBridgeConfig {
178 pub bridge_id: String,
179 pub trust_domain: String,
180 pub import: McpImportOptions,
181}
182
183pub struct McpBridge {
184 cfg: McpBridgeConfig,
185}
186
187impl McpBridge {
188 pub fn new(cfg: McpBridgeConfig) -> Self {
189 McpBridge { cfg }
190 }
191
192 pub fn import_tools(&self, tool_list: &McpToolList) -> Result<Vec<McpAction>, BridgeError> {
193 mcp_to_contract_actions(tool_list, &self.cfg.import)
194 }
195
196 pub fn export_tools(&self, actions: &[McpAction]) -> McpToolList {
197 contract_to_mcp_tools(actions)
198 }
199
200 pub fn normalize(&self, tool_name: &str) -> String {
202 normalize_tool_name(tool_name, self.cfg.import.name_prefix.as_deref())
203 }
204}
205
206impl Bridge for McpBridge {
207 fn bridge_id(&self) -> &str {
208 &self.cfg.bridge_id
209 }
210 fn kind(&self) -> BridgeKind {
211 BridgeKind::Mcp
212 }
213 fn trust_domain(&self) -> &str {
214 &self.cfg.trust_domain
215 }
216}