Skip to main content

tf_types/
bridge_mcp.rs

1//! MCP bridge — Rust mirror of `tools/tf-types-ts/src/core/bridge-mcp.ts`.
2//!
3//! Translates between an MCP tool list and a partial agent-contract
4//! action array. The bridge does not speak MCP JSON-RPC itself; it only
5//! shapes the data so the AgentGuard sees the same actions whether the
6//! AI agent discovered them via `.tf/agent-contract.yaml` or via an MCP
7//! `tools/list` response.
8
9use 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/// One projected action; the shape mirrors the TS `Action` so contracts
44/// can be merged back into a YAML agent-contract.
45#[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
60const ACTION_NAME_RE: &str = r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$";
61
62fn normalize_tool_name(name: &str, prefix: Option<&str>) -> String {
63    let mut scrubbed = String::with_capacity(name.len());
64    let mut prev_underscore = false;
65    for ch in name.chars() {
66        if ch.is_ascii_alphanumeric() {
67            scrubbed.push(ch.to_ascii_lowercase());
68            prev_underscore = false;
69        } else if !prev_underscore {
70            scrubbed.push('_');
71            prev_underscore = true;
72        }
73    }
74    let trimmed = scrubbed.trim_matches('_').to_string();
75    let with_prefix = match prefix {
76        Some(p) if !p.is_empty() => format!("{}.{}", p, trimmed),
77        _ => trimmed,
78    };
79    if with_prefix.contains('.') {
80        with_prefix
81    } else {
82        format!("mcp.{}", with_prefix)
83    }
84}
85
86pub fn mcp_to_contract_actions(
87    tool_list: &McpToolList,
88    opts: &McpImportOptions,
89) -> Result<Vec<McpAction>, BridgeError> {
90    let default_risk = opts
91        .default_risk
92        .clone()
93        .unwrap_or_else(|| "R2".to_string());
94    let default_approval = opts
95        .default_approval
96        .clone()
97        .unwrap_or_else(|| "conditional".to_string());
98    let re = regex::Regex::new(ACTION_NAME_RE).expect("static regex");
99    let mut out = Vec::with_capacity(tool_list.tools.len());
100    for tool in &tool_list.tools {
101        if tool.name.is_empty() {
102            return Err(BridgeError::InvalidInput("MCP tool missing a name".into()));
103        }
104        let action_name = normalize_tool_name(&tool.name, opts.name_prefix.as_deref());
105        if !re.is_match(&action_name) {
106            return Err(BridgeError::InvalidInput(format!(
107                "MCP tool {} produced invalid action name {}",
108                tool.name, action_name
109            )));
110        }
111        let danger_tags = opts.danger_tag_map.get(&tool.name).cloned();
112        let action = McpAction {
113            name: action_name,
114            risk: default_risk.clone(),
115            approval: default_approval.clone(),
116            proof: opts.default_proof.clone(),
117            description: tool.description.clone(),
118            parameters: tool.input_schema.clone(),
119            danger_tags: danger_tags.filter(|t| !t.is_empty()),
120        };
121        out.push(action);
122    }
123    Ok(out)
124}
125
126pub fn contract_to_mcp_tools(actions: &[McpAction]) -> McpToolList {
127    let tools = actions
128        .iter()
129        .map(|action| {
130            let warning = match action.danger_tags.as_ref() {
131                Some(tags) if !tags.is_empty() => format!("⚠️ {}. ", tags.join(", ")),
132                _ => String::new(),
133            };
134            let description = format!(
135                "{}{}",
136                warning,
137                action.description.clone().unwrap_or_default()
138            )
139            .trim()
140            .to_string();
141            McpTool {
142                name: action.name.clone(),
143                description: if description.is_empty() {
144                    None
145                } else {
146                    Some(description)
147                },
148                input_schema: action.parameters.clone(),
149            }
150        })
151        .collect();
152    McpToolList { tools }
153}
154
155#[derive(Clone, Debug, Default)]
156pub struct McpBridgeConfig {
157    pub bridge_id: String,
158    pub trust_domain: String,
159    pub import: McpImportOptions,
160}
161
162pub struct McpBridge {
163    cfg: McpBridgeConfig,
164}
165
166impl McpBridge {
167    pub fn new(cfg: McpBridgeConfig) -> Self {
168        McpBridge { cfg }
169    }
170
171    pub fn import_tools(&self, tool_list: &McpToolList) -> Result<Vec<McpAction>, BridgeError> {
172        mcp_to_contract_actions(tool_list, &self.cfg.import)
173    }
174
175    pub fn export_tools(&self, actions: &[McpAction]) -> McpToolList {
176        contract_to_mcp_tools(actions)
177    }
178
179    /// Normalize a tool name the same way the bridge does at import time.
180    pub fn normalize(&self, tool_name: &str) -> String {
181        normalize_tool_name(tool_name, self.cfg.import.name_prefix.as_deref())
182    }
183}
184
185impl Bridge for McpBridge {
186    fn bridge_id(&self) -> &str {
187        &self.cfg.bridge_id
188    }
189    fn kind(&self) -> BridgeKind {
190        BridgeKind::Mcp
191    }
192    fn trust_domain(&self) -> &str {
193        &self.cfg.trust_domain
194    }
195}