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}