Skip to main content

subc_protocol/
tool_call.rs

1//! The body of a tool-call `REQUEST` frame on a bound route.
2//!
3//! The daemon splices route frames without reading their bodies, so this
4//! shape is a contract between consumers (the MCP gateway, model runners)
5//! and provider modules, not something the daemon enforces. Before this
6//! type existed every consumer carried its own struct and every provider its
7//! own reader, and the fields drifted: the gateway sent `progress_token`,
8//! a model runner sent only `name` and `arguments`, and a provider that
9//! needed the caller's tool-call id had no field to read it from.
10//!
11//! Decoding is deliberately tolerant of unknown members: a provider must
12//! never refuse a call because a newer consumer added a key it does not
13//! know. Omitted optionals decode as `None`; `None` optionals are omitted on
14//! the wire, so a body carrying neither optional serializes exactly as the
15//! two-field shape older consumers already send — this type is drop-in for
16//! them without a wire change.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21/// A tool invocation as carried on a route `REQUEST` frame.
22#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
23pub struct ToolCallRequest {
24    /// The provider's bare manifest tool name (no gateway prefix).
25    pub name: String,
26    /// The arguments exactly as the caller supplied them; consumers never
27    /// translate them, and the provider's manifest schema is what accepts
28    /// or rejects their shape.
29    pub arguments: Value,
30    /// The consumer's own identifier for this call, minted by whatever
31    /// dispatched it (a model runner's WAL intent id, a gateway request id).
32    /// Opaque to the daemon and to subc; unique per call on the consumer's
33    /// side, so a provider's at-most-once fence can key on it directly
34    /// instead of synthesizing an id from the call's contents.
35    ///
36    /// `None` is a statement about the PRODUCER, not the call: it means this
37    /// consumer did not supply an id, never that the call has no identity.
38    /// A reader must not collapse the two — the moment a legacy producer is
39    /// on the other end, treating `None` as "no id exists" and synthesizing
40    /// one silently reproduces exactly the failure this field exists to end.
41    /// A reader that synthesizes a fallback id when this is `None` must
42    /// record that the fallback fired (a fallback that never reports firing
43    /// is indistinguishable from a working component that is quietly wrong).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub tool_call_id: Option<String>,
46    /// An MCP progress token the consumer wants progress notifications
47    /// correlated to, when the caller requested progress. Opaque here.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub progress_token: Option<Value>,
50}
51
52impl ToolCallRequest {
53    /// A call with no consumer id and no progress token — the shape older
54    /// two-field consumers send.
55    pub fn new(name: impl Into<String>, arguments: Value) -> Self {
56        Self {
57            name: name.into(),
58            arguments,
59            tool_call_id: None,
60            progress_token: None,
61        }
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use serde_json::json;
69
70    #[test]
71    fn omitted_optionals_decode_as_none() {
72        let request: ToolCallRequest =
73            serde_json::from_value(json!({ "name": "grep", "arguments": { "q": "x" } }))
74                .expect("two-field body decodes");
75        assert_eq!(request.tool_call_id, None);
76        assert_eq!(request.progress_token, None);
77    }
78
79    #[test]
80    fn none_optionals_are_omitted_so_the_wire_matches_the_two_field_shape() {
81        let request = ToolCallRequest::new("grep", json!({ "q": "x" }));
82        let encoded = serde_json::to_value(&request).expect("encode");
83        assert_eq!(
84            encoded,
85            json!({ "name": "grep", "arguments": { "q": "x" } })
86        );
87    }
88
89    #[test]
90    fn tool_call_id_round_trips() {
91        let request = ToolCallRequest {
92            name: "grep".to_string(),
93            arguments: json!({ "q": "x" }),
94            tool_call_id: Some("wal-intent-42".to_string()),
95            progress_token: None,
96        };
97        let encoded = serde_json::to_value(&request).expect("encode");
98        assert_eq!(encoded["tool_call_id"], json!("wal-intent-42"));
99        let decoded: ToolCallRequest = serde_json::from_value(encoded).expect("decode");
100        assert_eq!(decoded, request);
101    }
102
103    #[test]
104    fn unknown_members_do_not_fail_a_provider_decode() {
105        // A newer consumer added a key this provider has never heard of; the
106        // call must still decode rather than refuse.
107        let request: ToolCallRequest = serde_json::from_value(json!({
108            "name": "grep",
109            "arguments": {},
110            "some_future_key": { "nested": true }
111        }))
112        .expect("unknown members are tolerated");
113        assert_eq!(request.name, "grep");
114    }
115}