Skip to main content

mcp_utils/
display_meta.rs

1//! Display metadata for tool responses.
2//!
3//! This module provides types for generating human-readable display metadata
4//! that can be sent alongside tool results via the MCP `_meta` field.
5
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10/// Human-readable display metadata for a tool operation.
11///
12/// Contains a pre-computed `title` (e.g., "Read file") and `value`
13/// (e.g., "Cargo.toml, 156 lines") that consumers render directly.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct ToolDisplayMeta {
16    pub title: String,
17    pub value: String,
18}
19
20impl ToolDisplayMeta {
21    pub fn new(title: impl Into<String>, value: impl Into<String>) -> Self {
22        Self { title: title.into(), value: value.into() }
23    }
24}
25
26/// Full file contents for a diff, sent as metadata so the ACP layer
27/// can emit a first-class `ToolCallContent::Diff`.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct FileDiff {
30    pub path: String,
31    /// Original file content (`None` for new files).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub old_text: Option<String>,
34    /// Content after the edit/write.
35    pub new_text: String,
36}
37
38/// A snapshot of the agent's current task plan.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct PlanMeta {
41    pub entries: Vec<PlanMetaEntry>,
42}
43
44/// A single entry in a plan.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct PlanMetaEntry {
47    pub content: String,
48    pub status: PlanMetaStatus,
49}
50
51/// Execution status of a plan entry.
52#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
53#[serde(rename_all = "snake_case")]
54pub enum PlanMetaStatus {
55    Pending,
56    InProgress,
57    Completed,
58}
59
60/// Typed wrapper for the MCP `_meta` field on tool results.
61///
62/// Wraps a [`ToolDisplayMeta`] so that tool output structs can use
63/// `Option<ToolResultMeta>` instead of `Option<serde_json::Value>`.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65pub struct ToolResultMeta {
66    pub display: ToolDisplayMeta,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub file_diff: Option<FileDiff>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub plan: Option<PlanMeta>,
71}
72
73impl From<ToolDisplayMeta> for ToolResultMeta {
74    fn from(display: ToolDisplayMeta) -> Self {
75        Self::new(display)
76    }
77}
78
79impl ToolResultMeta {
80    /// Create a new metadata wrapper with just display info.
81    pub fn new(display: ToolDisplayMeta) -> Self {
82        Self { display, file_diff: None, plan: None }
83    }
84
85    /// Create a metadata wrapper with a plan.
86    pub fn with_plan(display: ToolDisplayMeta, plan: PlanMeta) -> Self {
87        Self { display, file_diff: None, plan: Some(plan) }
88    }
89
90    /// Create a metadata wrapper with a file diff.
91    pub fn with_file_diff(display: ToolDisplayMeta, file_diff: FileDiff) -> Self {
92        Self { display, file_diff: Some(file_diff), plan: None }
93    }
94}
95
96/// Extract a lowercased file extension from a path, for use as a syntax hint.
97pub fn extension_hint(path: &str) -> String {
98    Path::new(path).extension().and_then(|ext| ext.to_str()).unwrap_or("").to_lowercase()
99}
100
101impl ToolResultMeta {
102    /// Convert this metadata wrapper into an ACP-compatible meta map.
103    pub fn into_map(self) -> serde_json::Map<String, serde_json::Value> {
104        match serde_json::to_value(self).expect("ToolResultMeta should serialize") {
105            serde_json::Value::Object(map) => map,
106            _ => unreachable!("ToolResultMeta should serialize to a JSON object"),
107        }
108    }
109
110    /// Deserialize metadata wrapper from an ACP-compatible meta map.
111    pub fn from_map(map: &serde_json::Map<String, serde_json::Value>) -> Option<Self> {
112        serde_json::from_value(serde_json::Value::Object(map.clone())).ok()
113    }
114}
115
116/// Helper to truncate a string for display purposes.
117///
118/// Truncates the string to `max_length` characters, adding "..." if truncated.
119pub fn truncate(s: &str, max_length: usize) -> String {
120    if s.chars().count() <= max_length {
121        s.to_string()
122    } else {
123        let mut truncated = s.chars().take(max_length.saturating_sub(3)).collect::<String>();
124        truncated.push_str("...");
125        truncated
126    }
127}
128
129/// Extract the filename from a path, handling both Unix and Windows separators.
130pub fn basename(path: &str) -> String {
131    let platform_basename = std::path::Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
132
133    if platform_basename.contains('\\') {
134        path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
135    } else {
136        platform_basename.to_string()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn display(title: &str, value: &str) -> ToolDisplayMeta {
145        ToolDisplayMeta::new(title, value)
146    }
147
148    fn assert_serde_roundtrip<T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug>(val: &T) {
149        let json = serde_json::to_string(val).unwrap();
150        let parsed: T = serde_json::from_str(&json).unwrap();
151        assert_eq!(&parsed, val);
152    }
153
154    fn assert_map_roundtrip(meta: &ToolResultMeta) {
155        let map = meta.clone().into_map();
156        let parsed = ToolResultMeta::from_map(&map).expect("should deserialize");
157        assert_eq!(&parsed, meta);
158    }
159
160    fn sample_diff(old_text: Option<&str>) -> FileDiff {
161        FileDiff {
162            path: "/tmp/main.rs".to_string(),
163            old_text: old_text.map(str::to_string),
164            new_text: "new content".to_string(),
165        }
166    }
167
168    fn sample_plan() -> PlanMeta {
169        PlanMeta {
170            entries: vec![
171                PlanMetaEntry { content: "Research AI agents".into(), status: PlanMetaStatus::Completed },
172                PlanMetaEntry { content: "Implement tracking".into(), status: PlanMetaStatus::InProgress },
173                PlanMetaEntry { content: "Write tests".into(), status: PlanMetaStatus::Pending },
174            ],
175        }
176    }
177
178    #[test]
179    fn test_new_sets_title_and_value() {
180        let meta = display("Read file", "Cargo.toml, 156 lines");
181        assert_eq!(meta.title, "Read file");
182        assert_eq!(meta.value, "Cargo.toml, 156 lines");
183    }
184
185    #[test]
186    fn test_serde_json_shape() {
187        let json = serde_json::to_value(display("Read file", "Cargo.toml")).unwrap();
188        assert_eq!(json["title"], "Read file");
189        assert_eq!(json["value"], "Cargo.toml");
190    }
191
192    #[test]
193    fn test_serde_roundtrips() {
194        assert_serde_roundtrip(&display("Grep", "'TODO' in src (42 matches)"));
195        assert_serde_roundtrip(&sample_diff(Some("old content")));
196        assert_serde_roundtrip(&sample_plan());
197
198        let result_meta: ToolResultMeta = display("Read file", "Cargo.toml, 156 lines").into();
199        assert_serde_roundtrip(&result_meta);
200    }
201
202    #[test]
203    fn test_tool_result_meta_map_roundtrips() {
204        let plain: ToolResultMeta = display("Read file", "Cargo.toml, 156 lines").into();
205        assert_map_roundtrip(&plain);
206
207        let with_diff = ToolResultMeta::with_file_diff(display("Edit file", "main.rs"), sample_diff(Some("old")));
208        assert_map_roundtrip(&with_diff);
209
210        let with_plan = ToolResultMeta::with_plan(
211            display("Todo", "Research AI agents"),
212            PlanMeta {
213                entries: vec![PlanMetaEntry {
214                    content: "Research AI agents".into(),
215                    status: PlanMetaStatus::InProgress,
216                }],
217            },
218        );
219        assert_map_roundtrip(&with_plan);
220    }
221
222    #[test]
223    fn test_tool_result_meta_from_invalid_map_returns_none() {
224        let map = serde_json::Map::from_iter([(
225            "display".to_string(),
226            serde_json::Value::String("not an object".to_string()),
227        )]);
228        assert!(ToolResultMeta::from_map(&map).is_none());
229    }
230
231    #[test]
232    fn test_into_result_meta() {
233        let d = display("Write file", "main.rs");
234        let meta: ToolResultMeta = d.clone().into();
235        assert_eq!(meta, ToolResultMeta { display: d, file_diff: None, plan: None });
236    }
237
238    #[test]
239    fn test_optional_fields_omitted_when_none() {
240        let diff_json = serde_json::to_value(sample_diff(None)).unwrap();
241        assert!(diff_json.get("old_text").is_none());
242
243        let meta_json = serde_json::to_value::<ToolResultMeta>(display("Read", "f.rs").into()).unwrap();
244        assert!(meta_json.get("plan").is_none());
245        assert!(meta_json.get("file_diff").is_none());
246    }
247
248    #[test]
249    fn test_file_diff_missing_old_text_defaults_to_none() {
250        let parsed: FileDiff = serde_json::from_str(r#"{"path":"/tmp/f.rs","new_text":"content"}"#).unwrap();
251        assert_eq!(parsed.old_text, None);
252    }
253
254    #[test]
255    fn test_extension_hint() {
256        for (path, expected) in
257            [("/path/to/main.rs", "rs"), ("README.MD", "md"), ("Makefile", ""), ("/foo/bar/baz.tsx", "tsx")]
258        {
259            assert_eq!(extension_hint(path), expected, "path: {path}");
260        }
261    }
262
263    #[test]
264    fn test_truncate() {
265        assert_eq!(truncate("short", 10), "short");
266
267        let long = truncate("cargo check --message-format=json --locked", 20);
268        assert!(long.chars().count() <= 20);
269        assert!(long.ends_with("..."));
270
271        let multibyte = truncate("こんにちは世界テスト文字列", 8);
272        assert_eq!(multibyte.chars().count(), 8);
273        assert!(multibyte.ends_with("..."));
274    }
275
276    #[test]
277    fn test_basename() {
278        for (path, expected) in [
279            ("/Users/josh/code/aether/Cargo.toml", "Cargo.toml"),
280            (r"C:\Users\josh\code\aether\Cargo.toml", "Cargo.toml"),
281            ("Cargo.toml", "Cargo.toml"),
282        ] {
283            assert_eq!(basename(path), expected, "path: {path}");
284        }
285    }
286
287    #[test]
288    fn test_plan_meta_status_serde_snake_case() {
289        let json = serde_json::to_value(PlanMetaStatus::InProgress).unwrap();
290        assert_eq!(json, serde_json::Value::String("in_progress".to_string()));
291    }
292}