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
60/// Valid action names are two or more dot-separated segments, each
61/// `[a-z][a-z0-9_]*` (formerly the pattern
62/// `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`).
63fn 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; // empty segment (leading/trailing/double dot)
69        };
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    /// Normalize a tool name the same way the bridge does at import time.
201    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}