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