Skip to main content

forge_sdk/mcp/
tool.rs

1//! The `Tool` trait and its I/O types.
2
3use base64::Engine as _;
4use serde_json::Value;
5
6/// Structured input handed to a tool's `call`.
7#[derive(Debug, Clone)]
8pub struct ToolInput {
9    /// The arguments object, exactly as the client sent it.
10    pub value: Value,
11}
12
13/// Output a tool returns.
14#[derive(Debug, Clone)]
15pub struct ToolOutput {
16    /// Content blocks - matches MCP `tools/call` response shape.
17    pub blocks: Vec<ToolOutputBlock>,
18    /// Whether this represents a tool failure.
19    pub is_error: bool,
20}
21
22impl ToolOutput {
23    /// Build a text-only successful output.
24    pub fn text(s: impl Into<String>) -> Self {
25        Self { blocks: vec![ToolOutputBlock::Text { text: s.into() }], is_error: false }
26    }
27
28    /// Build a text-only failed output: the reason the call did not do what
29    /// it was asked.
30    pub fn error(s: impl Into<String>) -> Self {
31        Self { blocks: vec![ToolOutputBlock::Text { text: s.into() }], is_error: true }
32    }
33
34    /// Serialise to the JSON shape MCP expects.
35    pub(crate) fn to_mcp_content(&self) -> Vec<Value> {
36        self.blocks
37            .iter()
38            .map(|b| match b {
39                ToolOutputBlock::Text { text } => {
40                    serde_json::json!({ "type": "text", "text": text })
41                }
42                // The MCP image shape, which carries the bytes base64 - the
43                // encoding the CLI's own image blocks arrive in.
44                ToolOutputBlock::Image { mime_type, data } => serde_json::json!({
45                    "type": "image",
46                    "data": base64::engine::general_purpose::STANDARD.encode(data),
47                    "mimeType": mime_type,
48                }),
49            })
50            .collect()
51    }
52}
53
54/// A single content block returned from a tool. MCP supports resources
55/// too; add a variant here when the binary actually asks for one.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ToolOutputBlock {
58    /// The text payload.
59    Text { text: String },
60    /// An image's bytes and the mime type they are in. Encoded as the MCP
61    /// image content shape at the boundary, which carries `data` base64.
62    Image { mime_type: String, data: Vec<u8> },
63}
64
65/// A registered MCP tool. Implementations describe themselves (name,
66/// description, schema) and implement the async `call` method that
67/// produces an output for the given input.
68#[async_trait::async_trait]
69pub trait Tool: Send + Sync {
70    /// Tool name. Exposed to the model as `mcp__<server>__<tool>`.
71    ///
72    /// `'static` rather than borrowed from `&self`: a tool's identity is
73    /// fixed at compile time, and eliding the lifetime here made every
74    /// impl in the tree trip `clippy::unnecessary_literal_bound`.
75    fn name(&self) -> &'static str;
76
77    /// One-line description.
78    fn description(&self) -> &'static str;
79
80    /// JSON Schema describing the tool's arguments.
81    fn input_schema(&self) -> Value;
82
83    /// Execute the tool.
84    async fn call(&self, input: ToolInput) -> ToolOutput;
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    /// An image reaches the CLI as the MCP image content shape, which carries
92    /// the bytes base64 under `data` and the mime beside them - the same shape
93    /// a screenshot from any MCP server arrives in.
94    #[test]
95    fn an_image_block_crosses_as_an_mcp_image_content_block() {
96        let output = ToolOutput {
97            blocks: vec![ToolOutputBlock::Image {
98                mime_type: "image/png".to_owned(),
99                data: vec![0x00, 0xff, 0x10],
100            }],
101            is_error: false,
102        };
103        let content = output.to_mcp_content();
104        assert_eq!(content.len(), 1, "one block in, one block out");
105        assert_eq!(content[0]["type"], "image");
106        assert_eq!(content[0]["mimeType"], "image/png");
107        assert_eq!(content[0]["data"], "AP8Q", "the bytes base64-encoded, in order");
108        assert!(!output.is_error, "an image is a result, not a failure");
109    }
110
111    /// Text and failure keep the shapes they had: a text block, and the same
112    /// block marked as the call's error.
113    #[test]
114    fn text_and_failure_cross_as_text_blocks() {
115        let output = ToolOutput::error("no browser-capable client connected");
116        assert!(output.is_error, "the failure arm marks the call failed");
117        let content = output.to_mcp_content();
118        assert_eq!(content[0]["type"], "text");
119        assert_eq!(content[0]["text"], "no browser-capable client connected");
120    }
121}