ftl_sdk/
lib.rs

1//! Thin SDK providing MCP protocol types for FTL tool development.
2//!
3//! This crate provides only the type definitions needed to implement
4//! MCP-compliant tools. It does not include any HTTP server logic,
5//! allowing you to use any web framework of your choice.
6
7// Re-export macros when the feature is enabled
8#[cfg(feature = "macros")]
9pub use ftl_sdk_macros::tools;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// Tool metadata returned by GET requests to tool endpoints
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ToolMetadata {
16    /// The name of the tool (must be unique within the gateway)
17    pub name: String,
18
19    /// Optional human-readable title for the tool
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub title: Option<String>,
22
23    /// Optional description of what the tool does
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub description: Option<String>,
26
27    /// JSON Schema describing the expected input parameters
28    #[serde(rename = "inputSchema")]
29    pub input_schema: Value,
30
31    /// Optional JSON Schema describing the output format
32    #[serde(rename = "outputSchema", skip_serializing_if = "Option::is_none")]
33    pub output_schema: Option<Value>,
34
35    /// Optional annotations providing hints about tool behavior
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub annotations: Option<ToolAnnotations>,
38
39    /// Optional metadata for tool-specific extensions
40    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
41    pub meta: Option<Value>,
42}
43
44/// Annotations providing hints about tool behavior
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ToolAnnotations {
47    /// Optional title annotation
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub title: Option<String>,
50
51    /// Hint that the tool is read-only (doesn't modify state)
52    #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
53    pub read_only_hint: Option<bool>,
54
55    /// Hint that the tool may perform destructive operations
56    #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
57    pub destructive_hint: Option<bool>,
58
59    /// Hint that the tool is idempotent (same input → same output)
60    #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
61    pub idempotent_hint: Option<bool>,
62
63    /// Hint that the tool accepts open-world inputs
64    #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
65    pub open_world_hint: Option<bool>,
66}
67
68/// Response format for tool execution (POST requests)
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ToolResponse {
71    /// Array of content items returned by the tool
72    pub content: Vec<ToolContent>,
73
74    /// Optional structured content matching the outputSchema
75    #[serde(rename = "structuredContent", skip_serializing_if = "Option::is_none")]
76    pub structured_content: Option<Value>,
77
78    /// Indicates if this response represents an error
79    #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
80    pub is_error: Option<bool>,
81}
82
83/// Content types that can be returned by tools
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(tag = "type")]
86pub enum ToolContent {
87    /// Text content
88    #[serde(rename = "text")]
89    Text {
90        /// The text content
91        text: String,
92        /// Optional annotations for this content
93        #[serde(skip_serializing_if = "Option::is_none")]
94        annotations: Option<ContentAnnotations>,
95    },
96
97    /// Image content
98    #[serde(rename = "image")]
99    Image {
100        /// Base64-encoded image data
101        data: String,
102        /// MIME type of the image (e.g., "image/png")
103        #[serde(rename = "mimeType")]
104        mime_type: String,
105        /// Optional annotations for this content
106        #[serde(skip_serializing_if = "Option::is_none")]
107        annotations: Option<ContentAnnotations>,
108    },
109
110    /// Audio content
111    #[serde(rename = "audio")]
112    Audio {
113        /// Base64-encoded audio data
114        data: String,
115        /// MIME type of the audio (e.g., "audio/wav")
116        #[serde(rename = "mimeType")]
117        mime_type: String,
118        /// Optional annotations for this content
119        #[serde(skip_serializing_if = "Option::is_none")]
120        annotations: Option<ContentAnnotations>,
121    },
122
123    /// Resource reference
124    #[serde(rename = "resource")]
125    Resource {
126        /// The resource contents
127        resource: ResourceContents,
128        /// Optional annotations for this content
129        #[serde(skip_serializing_if = "Option::is_none")]
130        annotations: Option<ContentAnnotations>,
131    },
132}
133
134/// Annotations for content items
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ContentAnnotations {
137    /// Target audience for this content
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub audience: Option<Vec<String>>,
140
141    /// Priority of this content (0.0 to 1.0)
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub priority: Option<f32>,
144}
145
146/// Resource contents for resource-type content
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ResourceContents {
149    /// URI of the resource
150    pub uri: String,
151
152    /// MIME type of the resource
153    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
154    pub mime_type: Option<String>,
155
156    /// Text content of the resource
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub text: Option<String>,
159
160    /// Base64-encoded binary content of the resource
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub blob: Option<String>,
163}
164
165// Convenience constructors
166impl ToolResponse {
167    /// Create a simple text response
168    pub fn text(text: impl Into<String>) -> Self {
169        Self {
170            content: vec![ToolContent::Text {
171                text: text.into(),
172                annotations: None,
173            }],
174            structured_content: None,
175            is_error: None,
176        }
177    }
178
179    /// Create an error response
180    pub fn error(error: impl Into<String>) -> Self {
181        Self {
182            content: vec![ToolContent::Text {
183                text: error.into(),
184                annotations: None,
185            }],
186            structured_content: None,
187            is_error: Some(true),
188        }
189    }
190
191    /// Create a response with structured content
192    pub fn with_structured(text: impl Into<String>, structured: Value) -> Self {
193        Self {
194            content: vec![ToolContent::Text {
195                text: text.into(),
196                annotations: None,
197            }],
198            structured_content: Some(structured),
199            is_error: None,
200        }
201    }
202}
203
204impl ToolContent {
205    /// Create a text content item
206    pub fn text(text: impl Into<String>) -> Self {
207        Self::Text {
208            text: text.into(),
209            annotations: None,
210        }
211    }
212
213    /// Create an image content item
214    pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
215        Self::Image {
216            data: data.into(),
217            mime_type: mime_type.into(),
218            annotations: None,
219        }
220    }
221}
222
223// Response macros for ergonomic tool responses
224#[cfg(feature = "macros")]
225#[macro_export]
226macro_rules! text {
227    ($($arg:tt)*) => {
228        $crate::ToolResponse::text(format!($($arg)*))
229    };
230}
231
232#[cfg(feature = "macros")]
233#[macro_export]
234macro_rules! error {
235    ($($arg:tt)*) => {
236        $crate::ToolResponse::error(format!($($arg)*))
237    };
238}
239
240#[cfg(feature = "macros")]
241#[macro_export]
242macro_rules! structured {
243    ($data:expr, $($text:tt)*) => {
244        $crate::ToolResponse::with_structured(format!($($text)*), $data)
245    };
246}
247
248#[cfg(test)]
249mod tests {
250    use serde_json::json;
251
252    use super::*;
253
254    #[test]
255    fn test_tool_response_text() {
256        let response = ToolResponse::text("Hello, world!");
257        assert_eq!(response.content.len(), 1);
258        assert!(response.is_error.is_none());
259    }
260
261    #[test]
262    fn test_tool_response_error() {
263        let response = ToolResponse::error("Something went wrong");
264        assert_eq!(response.is_error, Some(true));
265    }
266
267    #[test]
268    fn test_serialization() {
269        let metadata = ToolMetadata {
270            name: "test-tool".to_string(),
271            title: None,
272            description: Some("A test tool".to_string()),
273            input_schema: json!({
274                "type": "object",
275                "properties": {
276                    "input": { "type": "string" }
277                }
278            }),
279            output_schema: None,
280            annotations: None,
281            meta: None,
282        };
283
284        let Ok(json) = serde_json::to_string(&metadata) else {
285            // In tests, we can use assert! with a condition that will fail
286            assert!(
287                serde_json::to_string(&metadata).is_ok(),
288                "Failed to serialize metadata"
289            );
290            return;
291        };
292        assert!(json.contains("\"name\":\"test-tool\""));
293        assert!(!json.contains("\"title\""));
294        assert!(json.contains("\"description\":\"A test tool\""));
295    }
296
297    #[cfg(all(test, feature = "macros"))]
298    #[test]
299    fn test_response_macros() {
300        // Test text! macro
301        let response = text!("Hello, {}", "world");
302        assert_eq!(response.content.len(), 1);
303        if let Some(ToolContent::Text { text, .. }) = response.content.first() {
304            assert_eq!(text, "Hello, world");
305        } else {
306            // This assertion will fail and provide a clear error message
307            assert!(
308                matches!(response.content.first(), Some(ToolContent::Text { .. })),
309                "Expected text content"
310            );
311        }
312
313        // Test error! macro
314        let response = error!("Error: {}", 42);
315        assert_eq!(response.is_error, Some(true));
316
317        // Test structured! macro
318        let data = json!({"status": "ok"});
319        let response = structured!(data.clone(), "Operation {}", "successful");
320        assert_eq!(response.structured_content, Some(data));
321    }
322}