turbomcp_protocol/types/
tools.rs

1//! Types for the MCP tool-calling system.
2//!
3//! This module defines the data structures for defining tools, their input/output schemas,
4//! and the requests and responses used to list and execute them, as specified by the MCP standard.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::{content::ContentBlock, core::Cursor};
10
11/// Optional metadata hints about a tool's behavior.
12///
13/// **Critical Warning** (from MCP spec):
14/// > "All properties in ToolAnnotations are **hints**. They are not guaranteed to
15/// > provide a faithful description of tool behavior. **Clients should never make
16/// > tool use decisions based on ToolAnnotations received from untrusted servers.**"
17///
18/// These fields are useful for UI display and general guidance, but should never
19/// be trusted for security decisions or behavioral assumptions.
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct ToolAnnotations {
22    /// A user-friendly title for display in UIs (hint only).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub title: Option<String>,
25    /// Role-based audience hint. Per spec, should be `"user"` or `"assistant"` (hint only).
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub audience: Option<Vec<String>>,
28    /// Subjective priority for UI sorting (hint only, often ignored).
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub priority: Option<f64>,
31    /// **Hint** that the tool may perform destructive actions (e.g., deleting data).
32    ///
33    /// Do not trust this for security decisions. Default: `true` if not specified.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    #[serde(rename = "destructiveHint")]
36    pub destructive_hint: Option<bool>,
37    /// **Hint** that repeated calls with same args have no additional effects.
38    ///
39    /// Useful for retry logic, but verify actual behavior. Default: `false` if not specified.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    #[serde(rename = "idempotentHint")]
42    pub idempotent_hint: Option<bool>,
43    /// **Hint** that the tool may interact with external systems or the real world.
44    ///
45    /// Do not trust this for sandboxing decisions. Default: `true` if not specified.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    #[serde(rename = "openWorldHint")]
48    pub open_world_hint: Option<bool>,
49    /// **Hint** that the tool does not modify state (read-only).
50    ///
51    /// Do not trust this for security decisions. Default: `false` if not specified.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    #[serde(rename = "readOnlyHint")]
54    pub read_only_hint: Option<bool>,
55
56    /// **Hint** for task augmentation support (MCP 2025-11-25 draft, SEP-1686)
57    ///
58    /// Indicates whether this tool supports task-augmented invocation:
59    /// - `never` (default): Tool MUST NOT be invoked as a task
60    /// - `optional`: Tool MAY be invoked as a task or normal request
61    /// - `always`: Tool SHOULD be invoked as a task (server may reject non-task calls)
62    ///
63    /// This is a **hint** and does not guarantee behavioral conformance.
64    ///
65    /// ## Capability Requirements
66    ///
67    /// If `tasks.requests.tools.call` capability is false, clients MUST ignore this hint.
68    /// If capability is true:
69    /// - `taskHint` absent or `"never"`: MUST NOT invoke as task
70    /// - `taskHint: "optional"`: MAY invoke as task
71    /// - `taskHint: "always"`: SHOULD invoke as task
72    #[cfg(feature = "mcp-tasks")]
73    #[serde(skip_serializing_if = "Option::is_none")]
74    #[serde(rename = "taskHint")]
75    pub task_hint: Option<TaskHint>,
76
77    /// Custom application-specific hints.
78    #[serde(flatten)]
79    pub custom: HashMap<String, serde_json::Value>,
80}
81
82/// Task hint for tool invocation (MCP 2025-11-25 draft, SEP-1686)
83///
84/// Indicates how a tool should be invoked with respect to task augmentation.
85#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
86#[serde(rename_all = "lowercase")]
87#[cfg(feature = "mcp-tasks")]
88pub enum TaskHint {
89    /// Tool MUST NOT be invoked as a task (default behavior)
90    Never,
91    /// Tool MAY be invoked as either a task or normal request
92    Optional,
93    /// Tool SHOULD be invoked as a task (server may reject non-task calls)
94    Always,
95}
96
97/// Represents a tool that can be executed by an MCP server
98///
99/// A `Tool` definition includes its programmatic name, a human-readable description,
100/// and JSON schemas for its inputs and outputs.
101///
102/// ## Version Support
103/// - MCP 2025-06-18: name, title, description, inputSchema, outputSchema, annotations, _meta
104/// - MCP 2025-11-25 draft (SEP-973): + icons
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct Tool {
107    /// The programmatic name of the tool, used to identify it in `CallToolRequest`.
108    pub name: String,
109
110    /// An optional, user-friendly title for the tool. Display name precedence is: `title`, `annotations.title`, then `name`.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub title: Option<String>,
113
114    /// A human-readable description of what the tool does, which can be used by clients or LLMs.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub description: Option<String>,
117
118    /// The JSON Schema object defining the parameters the tool accepts.
119    #[serde(rename = "inputSchema")]
120    pub input_schema: ToolInputSchema,
121
122    /// An optional JSON Schema object defining the structure of the tool's successful output.
123    #[serde(rename = "outputSchema", skip_serializing_if = "Option::is_none")]
124    pub output_schema: Option<ToolOutputSchema>,
125
126    /// Optional, additional metadata providing hints about the tool's behavior.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub annotations: Option<ToolAnnotations>,
129
130    /// Optional set of icons for UI display (MCP 2025-11-25 draft, SEP-973)
131    #[cfg(feature = "mcp-icons")]
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub icons: Option<Vec<super::core::Icon>>,
134
135    /// A general-purpose metadata field for custom data.
136    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
137    pub meta: Option<HashMap<String, serde_json::Value>>,
138}
139
140impl Default for Tool {
141    fn default() -> Self {
142        Self {
143            name: "unnamed_tool".to_string(), // Must have a valid name for MCP compliance
144            title: None,
145            description: None,
146            input_schema: ToolInputSchema::default(),
147            output_schema: None,
148            annotations: None,
149            #[cfg(feature = "mcp-icons")]
150            icons: None,
151            meta: None,
152        }
153    }
154}
155
156impl Tool {
157    /// Creates a new `Tool` with a given name.
158    ///
159    /// # Panics
160    /// Panics if the name is empty or contains only whitespace.
161    pub fn new(name: impl Into<String>) -> Self {
162        let name = name.into();
163        assert!(!name.trim().is_empty(), "Tool name cannot be empty");
164        Self {
165            name,
166            title: None,
167            description: None,
168            input_schema: ToolInputSchema::default(),
169            output_schema: None,
170            annotations: None,
171            #[cfg(feature = "mcp-icons")]
172            icons: None,
173            meta: None,
174        }
175    }
176
177    /// Creates a new `Tool` with a name and a description.
178    ///
179    /// # Panics
180    /// Panics if the name is empty or contains only whitespace.
181    pub fn with_description(name: impl Into<String>, description: impl Into<String>) -> Self {
182        let name = name.into();
183        assert!(!name.trim().is_empty(), "Tool name cannot be empty");
184        Self {
185            name,
186            title: None,
187            description: Some(description.into()),
188            input_schema: ToolInputSchema::default(),
189            output_schema: None,
190            annotations: None,
191            #[cfg(feature = "mcp-icons")]
192            icons: None,
193            meta: None,
194        }
195    }
196
197    /// Sets the input schema for this tool.
198    ///
199    /// # Example
200    /// ```
201    /// # use turbomcp_protocol::types::{Tool, ToolInputSchema};
202    /// let schema = ToolInputSchema::empty();
203    /// let tool = Tool::new("my_tool").with_input_schema(schema);
204    /// ```
205    pub fn with_input_schema(mut self, schema: ToolInputSchema) -> Self {
206        self.input_schema = schema;
207        self
208    }
209
210    /// Sets the output schema for this tool.
211    pub fn with_output_schema(mut self, schema: ToolOutputSchema) -> Self {
212        self.output_schema = Some(schema);
213        self
214    }
215
216    /// Sets the user-friendly title for this tool.
217    pub fn with_title(mut self, title: impl Into<String>) -> Self {
218        self.title = Some(title.into());
219        self
220    }
221
222    /// Sets the annotations for this tool.
223    pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
224        self.annotations = Some(annotations);
225        self
226    }
227}
228
229/// Defines the structure of the arguments a tool accepts, as a JSON Schema object.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ToolInputSchema {
232    /// The type of the schema, which must be "object" for tool inputs.
233    #[serde(rename = "type")]
234    pub schema_type: String,
235    /// A map defining the properties (parameters) the tool accepts.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub properties: Option<HashMap<String, serde_json::Value>>,
238    /// A list of property names that are required.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub required: Option<Vec<String>>,
241    /// Whether additional, unspecified properties are allowed.
242    #[serde(
243        rename = "additionalProperties",
244        skip_serializing_if = "Option::is_none"
245    )]
246    pub additional_properties: Option<bool>,
247}
248
249impl Default for ToolInputSchema {
250    /// Creates a default `ToolInputSchema` that accepts an empty object.
251    fn default() -> Self {
252        Self {
253            schema_type: "object".to_string(),
254            properties: None,
255            required: None,
256            additional_properties: None,
257        }
258    }
259}
260
261impl ToolInputSchema {
262    /// Creates a new, empty input schema that accepts no parameters.
263    pub fn empty() -> Self {
264        Self::default()
265    }
266
267    /// Creates a new schema with a given set of properties.
268    pub fn with_properties(properties: HashMap<String, serde_json::Value>) -> Self {
269        Self {
270            schema_type: "object".to_string(),
271            properties: Some(properties),
272            required: None,
273            additional_properties: None,
274        }
275    }
276
277    /// Creates a new schema with a given set of properties and a list of required properties.
278    pub fn with_required_properties(
279        properties: HashMap<String, serde_json::Value>,
280        required: Vec<String>,
281    ) -> Self {
282        Self {
283            schema_type: "object".to_string(),
284            properties: Some(properties),
285            required: Some(required),
286            additional_properties: Some(false),
287        }
288    }
289
290    /// Adds a property to the schema using a builder pattern.
291    ///
292    /// # Example
293    /// ```
294    /// # use turbomcp_protocol::types::ToolInputSchema;
295    /// # use serde_json::json;
296    /// let schema = ToolInputSchema::empty()
297    ///     .add_property("name".to_string(), json!({ "type": "string" }));
298    /// ```
299    pub fn add_property(mut self, name: String, property: serde_json::Value) -> Self {
300        self.properties
301            .get_or_insert_with(HashMap::new)
302            .insert(name, property);
303        self
304    }
305
306    /// Marks a property as required using a builder pattern.
307    ///
308    /// # Example
309    /// ```
310    /// # use turbomcp_protocol::types::ToolInputSchema;
311    /// # use serde_json::json;
312    /// let schema = ToolInputSchema::empty()
313    ///     .add_property("name".to_string(), json!({ "type": "string" }))
314    ///     .require_property("name".to_string());
315    /// ```
316    pub fn require_property(mut self, name: String) -> Self {
317        let required = self.required.get_or_insert_with(Vec::new);
318        if !required.contains(&name) {
319            required.push(name);
320        }
321        self
322    }
323}
324
325/// Defines the structure of a tool's successful output, as a JSON Schema object.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct ToolOutputSchema {
328    /// The type of the schema, which must be "object" for tool outputs.
329    #[serde(rename = "type")]
330    pub schema_type: String,
331    /// A map defining the properties of the output object.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub properties: Option<HashMap<String, serde_json::Value>>,
334    /// A list of property names in the output that are required.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub required: Option<Vec<String>>,
337    /// Whether additional, unspecified properties are allowed in the output.
338    #[serde(
339        rename = "additionalProperties",
340        skip_serializing_if = "Option::is_none"
341    )]
342    pub additional_properties: Option<bool>,
343}
344
345/// A request to list the available tools on a server.
346#[derive(Debug, Clone, Serialize, Deserialize, Default)]
347pub struct ListToolsRequest {
348    /// An optional cursor for pagination. If provided, the server should return
349    /// the next page of results starting after this cursor.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub cursor: Option<Cursor>,
352    /// Optional metadata for the request.
353    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
354    pub _meta: Option<serde_json::Value>,
355}
356
357/// The result of a `ListToolsRequest`.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct ListToolsResult {
360    /// The list of available tools for the current page.
361    pub tools: Vec<Tool>,
362    /// An optional continuation token for retrieving the next page of results.
363    /// If `None`, there are no more results.
364    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
365    pub next_cursor: Option<Cursor>,
366    /// Optional metadata for the result.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub _meta: Option<serde_json::Value>,
369}
370
371/// A request to execute a specific tool.
372///
373/// ## Version Support
374/// - MCP 2025-06-18: name, arguments, _meta
375/// - MCP 2025-11-25 draft (SEP-1686): + task (optional task augmentation)
376///
377/// ## Task Augmentation
378///
379/// When the `task` field is present, the receiver responds immediately with
380/// a `CreateTaskResult` containing a task ID. The actual tool result is available
381/// later via `tasks/result`.
382///
383/// ```rust,ignore
384/// use turbomcp_protocol::types::{CallToolRequest, tasks::TaskMetadata};
385///
386/// let request = CallToolRequest {
387///     name: "long_running_tool".to_string(),
388///     arguments: Some(json!({"data": "value"})),
389///     task: Some(TaskMetadata { ttl: Some(300_000) }), // 5 minute lifetime
390///     _meta: None,
391/// };
392/// ```
393#[derive(Debug, Clone, Serialize, Deserialize, Default)]
394pub struct CallToolRequest {
395    /// The programmatic name of the tool to call.
396    pub name: String,
397
398    /// The arguments to pass to the tool, conforming to its `input_schema`.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub arguments: Option<HashMap<String, serde_json::Value>>,
401
402    /// Optional task metadata for task-augmented requests (MCP 2025-11-25 draft)
403    ///
404    /// When present, this request will be executed asynchronously and the receiver
405    /// will respond immediately with a `CreateTaskResult`. The actual tool result
406    /// is available later via `tasks/result`.
407    ///
408    /// Requires:
409    /// - Server capability: `tasks.requests.tools.call`
410    /// - Tool annotation: `taskHint` must be "optional" or "always" (or absent/"never" for default)
411    #[cfg(feature = "mcp-tasks")]
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub task: Option<crate::types::tasks::TaskMetadata>,
414
415    /// Optional metadata for the request.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub _meta: Option<serde_json::Value>,
418}
419
420/// The result of a `CallToolRequest`.
421#[derive(Debug, Clone, Serialize, Deserialize, Default)]
422pub struct CallToolResult {
423    /// The output of the tool, typically as a series of text or other content blocks. This is required.
424    pub content: Vec<ContentBlock>,
425    /// An optional boolean indicating whether the tool execution resulted in an error.
426    ///
427    /// When `is_error` is `true`, all content blocks should be treated as error information.
428    /// The error message may span multiple text blocks for structured error reporting.
429    #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
430    pub is_error: Option<bool>,
431    /// Optional structured output from the tool, conforming to its `output_schema`.
432    ///
433    /// When present, this contains schema-validated JSON output that clients can parse
434    /// and use programmatically. Tools that return structured content SHOULD also include
435    /// the serialized JSON in a TextContent block for backward compatibility with clients
436    /// that don't support structured output.
437    ///
438    /// See [`Tool::output_schema`] for defining the expected structure.
439    #[serde(rename = "structuredContent", skip_serializing_if = "Option::is_none")]
440    pub structured_content: Option<serde_json::Value>,
441    /// Optional metadata for the result.
442    ///
443    /// This field is for client applications and tools to pass additional context that
444    /// should NOT be exposed to LLMs. Examples include tracking IDs, performance metrics,
445    /// cache status, or internal state information.
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub _meta: Option<serde_json::Value>,
448    /// Optional task ID when tool execution is augmented with task tracking (MCP 2025-11-25 draft - SEP-1686).
449    ///
450    /// When a tool call includes task metadata, the server creates a task to track the operation
451    /// and returns the task_id here. Clients can use this to monitor progress via tasks/get
452    /// or retrieve final results via tasks/result.
453    #[cfg(feature = "mcp-tasks")]
454    #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")]
455    pub task_id: Option<String>,
456}
457
458impl CallToolResult {
459    /// Extracts and concatenates all text content from the result.
460    ///
461    /// This is useful for simple text-only tools or when you want to present
462    /// all textual output as a single string.
463    ///
464    /// # Returns
465    ///
466    /// A single string containing all text blocks concatenated with newlines.
467    /// Returns an empty string if there are no text blocks.
468    ///
469    /// # Example
470    ///
471    /// ```rust
472    /// use turbomcp_protocol::types::{CallToolResult, ContentBlock, TextContent};
473    ///
474    /// let result = CallToolResult {
475    ///     content: vec![
476    ///         ContentBlock::Text(TextContent {
477    ///             text: "Line 1".to_string(),
478    ///             annotations: None,
479    ///             meta: None,
480    ///         }),
481    ///         ContentBlock::Text(TextContent {
482    ///             text: "Line 2".to_string(),
483    ///             annotations: None,
484    ///             meta: None,
485    ///         }),
486    ///     ],
487    ///     is_error: None,
488    ///     structured_content: None,
489    ///     _meta: None,
490    /// };
491    ///
492    /// assert_eq!(result.all_text(), "Line 1\nLine 2");
493    /// ```
494    pub fn all_text(&self) -> String {
495        self.content
496            .iter()
497            .filter_map(|block| match block {
498                ContentBlock::Text(text) => Some(text.text.as_str()),
499                _ => None,
500            })
501            .collect::<Vec<_>>()
502            .join("\n")
503    }
504
505    /// Returns the text content of the first text block, if any.
506    ///
507    /// This is a common pattern for simple tools that return a single text response.
508    ///
509    /// # Returns
510    ///
511    /// `Some(&str)` if the first content block is text, `None` otherwise.
512    ///
513    /// # Example
514    ///
515    /// ```rust
516    /// use turbomcp_protocol::types::{CallToolResult, ContentBlock, TextContent};
517    ///
518    /// let result = CallToolResult {
519    ///     content: vec![
520    ///         ContentBlock::Text(TextContent {
521    ///             text: "Hello, world!".to_string(),
522    ///             annotations: None,
523    ///             meta: None,
524    ///         }),
525    ///     ],
526    ///     is_error: None,
527    ///     structured_content: None,
528    ///     _meta: None,
529    /// };
530    ///
531    /// assert_eq!(result.first_text(), Some("Hello, world!"));
532    /// ```
533    pub fn first_text(&self) -> Option<&str> {
534        self.content.first().and_then(|block| match block {
535            ContentBlock::Text(text) => Some(text.text.as_str()),
536            _ => None,
537        })
538    }
539
540    /// Checks if the tool execution resulted in an error.
541    ///
542    /// # Returns
543    ///
544    /// `true` if `is_error` is explicitly set to `true`, `false` otherwise
545    /// (including when `is_error` is `None`).
546    ///
547    /// # Example
548    ///
549    /// ```rust
550    /// use turbomcp_protocol::types::CallToolResult;
551    ///
552    /// let success_result = CallToolResult {
553    ///     content: vec![],
554    ///     is_error: Some(false),
555    ///     structured_content: None,
556    ///     _meta: None,
557    /// };
558    /// assert!(!success_result.has_error());
559    ///
560    /// let error_result = CallToolResult {
561    ///     content: vec![],
562    ///     is_error: Some(true),
563    ///     structured_content: None,
564    ///     _meta: None,
565    /// };
566    /// assert!(error_result.has_error());
567    ///
568    /// let unspecified_result = CallToolResult {
569    ///     content: vec![],
570    ///     is_error: None,
571    ///     structured_content: None,
572    ///     _meta: None,
573    /// };
574    /// assert!(!unspecified_result.has_error());
575    /// ```
576    pub fn has_error(&self) -> bool {
577        self.is_error.unwrap_or(false)
578    }
579
580    /// Creates a user-friendly display string for the tool result.
581    ///
582    /// This method provides a formatted representation suitable for logging,
583    /// debugging, or displaying to end users. It handles multiple content types
584    /// and includes structured content and error information when present.
585    ///
586    /// # Returns
587    ///
588    /// A formatted string representing the tool result.
589    ///
590    /// # Example
591    ///
592    /// ```rust
593    /// use turbomcp_protocol::types::{CallToolResult, ContentBlock, TextContent};
594    ///
595    /// let result = CallToolResult {
596    ///     content: vec![
597    ///         ContentBlock::Text(TextContent {
598    ///             text: "Operation completed".to_string(),
599    ///             annotations: None,
600    ///             meta: None,
601    ///         }),
602    ///     ],
603    ///     is_error: Some(false),
604    ///     structured_content: None,
605    ///     _meta: None,
606    /// };
607    ///
608    /// let display = result.to_display_string();
609    /// assert!(display.contains("Operation completed"));
610    /// ```
611    pub fn to_display_string(&self) -> String {
612        let mut parts = Vec::new();
613
614        // Add error indicator if present
615        if self.has_error() {
616            parts.push("ERROR:".to_string());
617        }
618
619        // Process content blocks
620        for (i, block) in self.content.iter().enumerate() {
621            match block {
622                ContentBlock::Text(text) => {
623                    parts.push(text.text.clone());
624                }
625                ContentBlock::Image(img) => {
626                    parts.push(format!(
627                        "[Image: {} bytes, type: {}]",
628                        img.data.len(),
629                        img.mime_type
630                    ));
631                }
632                ContentBlock::Audio(audio) => {
633                    parts.push(format!(
634                        "[Audio: {} bytes, type: {}]",
635                        audio.data.len(),
636                        audio.mime_type
637                    ));
638                }
639                ContentBlock::ResourceLink(link) => {
640                    let desc = link.description.as_deref().unwrap_or("");
641                    let mime = link
642                        .mime_type
643                        .as_deref()
644                        .map(|m| format!(" [{}]", m))
645                        .unwrap_or_default();
646                    parts.push(format!(
647                        "[Resource: {}{}{}{}]",
648                        link.name,
649                        mime,
650                        if !desc.is_empty() { ": " } else { "" },
651                        desc
652                    ));
653                }
654                ContentBlock::Resource(_resource) => {
655                    parts.push(format!("[Embedded Resource #{}]", i + 1));
656                }
657                #[cfg(feature = "mcp-sampling-tools")]
658                ContentBlock::ToolUse(tool_use) => {
659                    parts.push(format!(
660                        "[Tool Use: {} (id: {})]",
661                        tool_use.name, tool_use.id
662                    ));
663                }
664                #[cfg(feature = "mcp-sampling-tools")]
665                ContentBlock::ToolResult(tool_result) => {
666                    parts.push(format!(
667                        "[Tool Result for: {}{}]",
668                        tool_result.tool_use_id,
669                        if tool_result.is_error.unwrap_or(false) {
670                            " (ERROR)"
671                        } else {
672                            ""
673                        }
674                    ));
675                }
676            }
677        }
678
679        // Add structured content indicator if present
680        if self.structured_content.is_some() {
681            parts.push("[Includes structured output]".to_string());
682        }
683
684        parts.join("\n")
685    }
686}