Skip to main content

rustlavel_mcp/
protocol.rs

1//! The MCP message shapes, as the specification defines them.
2//!
3//! Only the wire vocabulary lives here — method names, the handshake, the way
4//! a tool result is spelled. Both halves of the crate share it: the server
5//! writes these shapes and the client reads them, so a mistake in one is
6//! caught by a test of the other.
7
8use crate::schema::Schema;
9use rustlavel_core::Json;
10
11/// The protocol revision this crate implements.
12pub const PROTOCOL_VERSION: &str = "2025-06-18";
13
14/// Revisions we still understand, newest first.
15///
16/// A client that asks for an older revision gets it back rather than a
17/// failure: the message shapes we use are common to all three, and refusing
18/// would break desktop clients that have not caught up.
19pub const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
20
21/// Every method name in one place, so a typo is a compile error.
22pub mod method {
23    pub const INITIALIZE: &str = "initialize";
24    pub const INITIALIZED: &str = "notifications/initialized";
25    pub const PING: &str = "ping";
26    pub const TOOLS_LIST: &str = "tools/list";
27    pub const TOOLS_CALL: &str = "tools/call";
28    pub const RESOURCES_LIST: &str = "resources/list";
29    pub const RESOURCES_READ: &str = "resources/read";
30    pub const PROMPTS_LIST: &str = "prompts/list";
31    pub const PROMPTS_GET: &str = "prompts/get";
32}
33
34/// Who is on the other end: `{"name": "rustlavel", "version": "0.1.0"}`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Implementation {
37    pub name: String,
38    pub version: String,
39}
40
41impl Implementation {
42    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
43        Implementation { name: name.into(), version: version.into() }
44    }
45
46    pub fn to_json(&self) -> Json {
47        Json::object([
48            ("name", Json::from(self.name.clone())),
49            ("version", Json::from(self.version.clone())),
50        ])
51    }
52
53    pub fn from_json(value: &Json) -> Option<Implementation> {
54        Some(Implementation::new(
55            value.get("name")?.as_str()?,
56            value.get("version").and_then(Json::as_str).unwrap_or("unknown"),
57        ))
58    }
59}
60
61/// One piece of a tool result or a prompt message.
62///
63/// Only text for now. The variant exists so images and embedded resources can
64/// be added later without every caller changing shape.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Content {
67    Text(String),
68}
69
70impl Content {
71    pub fn text(text: impl Into<String>) -> Self {
72        Content::Text(text.into())
73    }
74
75    pub fn as_text(&self) -> &str {
76        match self {
77            Content::Text(text) => text,
78        }
79    }
80
81    pub fn to_json(&self) -> Json {
82        match self {
83            Content::Text(text) => Json::object([
84                ("type", Json::from("text")),
85                ("text", Json::from(text.clone())),
86            ]),
87        }
88    }
89
90    pub fn from_json(value: &Json) -> Option<Content> {
91        match value.get("type")?.as_str()? {
92            "text" => Some(Content::text(value.get("text")?.as_str()?)),
93            _ => None,
94        }
95    }
96}
97
98/// What `tools/call` answers with.
99///
100/// A failing tool is *not* a JSON-RPC error: the model asked for something and
101/// deserves to read why it did not work, so the failure travels as a normal
102/// result with `isError` set. Protocol mistakes — an unknown tool, arguments
103/// that do not match the schema — are the ones that become JSON-RPC errors.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ToolResult {
106    pub content: Vec<Content>,
107    pub is_error: bool,
108}
109
110impl ToolResult {
111    pub fn text(text: impl Into<String>) -> Self {
112        ToolResult { content: vec![Content::text(text)], is_error: false }
113    }
114
115    pub fn error(text: impl Into<String>) -> Self {
116        ToolResult { content: vec![Content::text(text)], is_error: true }
117    }
118
119    /// Turn whatever a handler returned into content.
120    ///
121    /// A string is already text; anything else is rendered as compact JSON,
122    /// which is what a model can actually read back.
123    pub fn from_value(value: Json) -> Self {
124        match value {
125            Json::String(text) => ToolResult::text(text),
126            other => ToolResult::text(other.to_string()),
127        }
128    }
129
130    /// Every text block joined — how a caller usually wants to read a result.
131    pub fn text_content(&self) -> String {
132        self.content.iter().map(Content::as_text).collect::<Vec<_>>().join("\n")
133    }
134
135    pub fn to_json(&self) -> Json {
136        Json::object([
137            ("content", Json::Array(self.content.iter().map(Content::to_json).collect())),
138            ("isError", Json::from(self.is_error)),
139        ])
140    }
141
142    pub fn from_json(value: &Json) -> Option<ToolResult> {
143        let content = value
144            .get("content")?
145            .as_array()?
146            .iter()
147            .filter_map(Content::from_json)
148            .collect();
149        Some(ToolResult {
150            content,
151            is_error: value.get("isError").and_then(Json::as_bool).unwrap_or(false),
152        })
153    }
154}
155
156/// A tool as it appears in `tools/list` — the client's view of one.
157#[derive(Debug, Clone, PartialEq)]
158pub struct ToolInfo {
159    pub name: String,
160    pub description: String,
161    /// Left as raw JSON: a client must pass a foreign server's schema through
162    /// untouched, whatever keywords it uses.
163    pub input_schema: Json,
164}
165
166impl ToolInfo {
167    pub fn to_json(&self) -> Json {
168        Json::object([
169            ("name", Json::from(self.name.clone())),
170            ("description", Json::from(self.description.clone())),
171            ("inputSchema", self.input_schema.clone()),
172        ])
173    }
174
175    pub fn from_json(value: &Json) -> Option<ToolInfo> {
176        Some(ToolInfo {
177            name: value.get("name")?.as_str()?.to_string(),
178            description: value.get("description").and_then(Json::as_str).unwrap_or("").to_string(),
179            input_schema: value.get("inputSchema").cloned().unwrap_or(Json::Null),
180        })
181    }
182
183    /// Build the descriptor for a tool defined in this process.
184    pub fn describe(name: &str, description: &str, schema: &Schema) -> ToolInfo {
185        ToolInfo {
186            name: name.to_string(),
187            description: description.to_string(),
188            input_schema: schema.to_json(),
189        }
190    }
191}
192
193/// A resource as it appears in `resources/list`.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct ResourceInfo {
196    pub uri: String,
197    pub name: String,
198    pub description: Option<String>,
199    pub mime_type: String,
200}
201
202impl ResourceInfo {
203    pub fn to_json(&self) -> Json {
204        let mut pairs = vec![
205            ("uri", Json::from(self.uri.clone())),
206            ("name", Json::from(self.name.clone())),
207            ("mimeType", Json::from(self.mime_type.clone())),
208        ];
209        if let Some(description) = &self.description {
210            pairs.push(("description", Json::from(description.clone())));
211        }
212        Json::object(pairs)
213    }
214
215    pub fn from_json(value: &Json) -> Option<ResourceInfo> {
216        Some(ResourceInfo {
217            uri: value.get("uri")?.as_str()?.to_string(),
218            name: value.get("name").and_then(Json::as_str).unwrap_or("").to_string(),
219            description: value.get("description").and_then(Json::as_str).map(str::to_string),
220            mime_type: value
221                .get("mimeType")
222                .and_then(Json::as_str)
223                .unwrap_or("text/plain")
224                .to_string(),
225        })
226    }
227}
228
229/// One argument a prompt accepts.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct PromptArgument {
232    pub name: String,
233    pub description: String,
234    pub required: bool,
235}
236
237impl PromptArgument {
238    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
239        PromptArgument { name: name.into(), description: description.into(), required: true }
240    }
241
242    pub fn optional(mut self) -> Self {
243        self.required = false;
244        self
245    }
246
247    pub fn to_json(&self) -> Json {
248        Json::object([
249            ("name", Json::from(self.name.clone())),
250            ("description", Json::from(self.description.clone())),
251            ("required", Json::from(self.required)),
252        ])
253    }
254
255    pub fn from_json(value: &Json) -> Option<PromptArgument> {
256        Some(PromptArgument {
257            name: value.get("name")?.as_str()?.to_string(),
258            description: value.get("description").and_then(Json::as_str).unwrap_or("").to_string(),
259            required: value.get("required").and_then(Json::as_bool).unwrap_or(false),
260        })
261    }
262}
263
264/// A prompt as it appears in `prompts/list`.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct PromptInfo {
267    pub name: String,
268    pub description: String,
269    pub arguments: Vec<PromptArgument>,
270}
271
272impl PromptInfo {
273    pub fn to_json(&self) -> Json {
274        Json::object([
275            ("name", Json::from(self.name.clone())),
276            ("description", Json::from(self.description.clone())),
277            ("arguments", Json::Array(self.arguments.iter().map(PromptArgument::to_json).collect())),
278        ])
279    }
280
281    pub fn from_json(value: &Json) -> Option<PromptInfo> {
282        Some(PromptInfo {
283            name: value.get("name")?.as_str()?.to_string(),
284            description: value.get("description").and_then(Json::as_str).unwrap_or("").to_string(),
285            arguments: value
286                .get("arguments")
287                .and_then(Json::as_array)
288                .unwrap_or(&[])
289                .iter()
290                .filter_map(PromptArgument::from_json)
291                .collect(),
292        })
293    }
294}
295
296/// Who is speaking in a prompt message.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub enum Role {
299    User,
300    Assistant,
301}
302
303impl Role {
304    pub fn as_str(self) -> &'static str {
305        match self {
306            Role::User => "user",
307            Role::Assistant => "assistant",
308        }
309    }
310
311    pub fn parse(value: &str) -> Option<Role> {
312        match value {
313            "user" => Some(Role::User),
314            "assistant" => Some(Role::Assistant),
315            _ => None,
316        }
317    }
318}
319
320/// One message of a rendered prompt.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct PromptMessage {
323    pub role: Role,
324    pub content: Content,
325}
326
327impl PromptMessage {
328    pub fn user(text: impl Into<String>) -> Self {
329        PromptMessage { role: Role::User, content: Content::text(text) }
330    }
331
332    pub fn assistant(text: impl Into<String>) -> Self {
333        PromptMessage { role: Role::Assistant, content: Content::text(text) }
334    }
335
336    pub fn to_json(&self) -> Json {
337        Json::object([
338            ("role", Json::from(self.role.as_str())),
339            ("content", self.content.to_json()),
340        ])
341    }
342
343    pub fn from_json(value: &Json) -> Option<PromptMessage> {
344        Some(PromptMessage {
345            role: Role::parse(value.get("role")?.as_str()?)?,
346            content: Content::from_json(value.get("content")?)?,
347        })
348    }
349}
350
351/// What `initialize` answers with — the client's picture of the server.
352#[derive(Debug, Clone, PartialEq)]
353pub struct ServerInfo {
354    pub protocol_version: String,
355    /// Left as raw JSON: capabilities grow every revision, and a client should
356    /// pass through what it does not recognise rather than dropping it.
357    pub capabilities: Json,
358    pub server: Implementation,
359    pub instructions: Option<String>,
360}
361
362impl ServerInfo {
363    pub fn to_json(&self) -> Json {
364        let mut pairs = vec![
365            ("protocolVersion", Json::from(self.protocol_version.clone())),
366            ("capabilities", self.capabilities.clone()),
367            ("serverInfo", self.server.to_json()),
368        ];
369        if let Some(instructions) = &self.instructions {
370            pairs.push(("instructions", Json::from(instructions.clone())));
371        }
372        Json::object(pairs)
373    }
374
375    pub fn from_json(value: &Json) -> Option<ServerInfo> {
376        Some(ServerInfo {
377            protocol_version: value.get("protocolVersion")?.as_str()?.to_string(),
378            capabilities: value.get("capabilities").cloned().unwrap_or(Json::Null),
379            server: Implementation::from_json(value.get("serverInfo")?)?,
380            instructions: value.get("instructions").and_then(Json::as_str).map(str::to_string),
381        })
382    }
383
384    /// Whether the server said it serves tools.
385    pub fn supports(&self, capability: &str) -> bool {
386        self.capabilities.get(capability).is_some()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn a_tool_result_uses_the_content_array_the_specification_defines() {
396        let result = ToolResult::text("22°C and clear");
397
398        assert_eq!(
399            result.to_json().to_string(),
400            r#"{"content":[{"text":"22°C and clear","type":"text"}],"isError":false}"#
401        );
402    }
403
404    #[test]
405    fn a_failed_tool_result_sets_is_error() {
406        let result = ToolResult::error("the upstream API is down");
407
408        assert_eq!(result.to_json().get("isError").unwrap().as_bool(), Some(true));
409        assert_eq!(ToolResult::from_json(&result.to_json()).unwrap(), result);
410    }
411
412    #[test]
413    fn a_handler_returning_structured_json_becomes_readable_text() {
414        let result = ToolResult::from_value(Json::object([("temp", Json::from(22))]));
415        assert_eq!(result.text_content(), r#"{"temp":22}"#);
416
417        // A plain string is handed over as-is, not quoted again.
418        assert_eq!(ToolResult::from_value(Json::from("plain")).text_content(), "plain");
419    }
420
421    #[test]
422    fn a_tool_descriptor_carries_name_description_and_input_schema() {
423        let schema = Schema::object().string("city", "The city to look up");
424        let info = ToolInfo::describe("weather", "Look up the weather", &schema);
425        let json = info.to_json();
426
427        assert_eq!(json.get("name").unwrap().as_str(), Some("weather"));
428        assert_eq!(json.get("description").unwrap().as_str(), Some("Look up the weather"));
429        assert_eq!(json.get("inputSchema.type").unwrap().as_str(), Some("object"));
430        assert_eq!(ToolInfo::from_json(&json).unwrap(), info);
431    }
432
433    #[test]
434    fn server_info_round_trips_through_the_handshake_shape() {
435        let info = ServerInfo {
436            protocol_version: PROTOCOL_VERSION.to_string(),
437            capabilities: Json::object([("tools", Json::Object(Default::default()))]),
438            server: Implementation::new("shop", "1.2.3"),
439            instructions: Some("Use `orders` to look up an order.".into()),
440        };
441
442        let decoded = ServerInfo::from_json(&info.to_json()).unwrap();
443        assert_eq!(decoded, info);
444        assert!(decoded.supports("tools"));
445        assert!(!decoded.supports("prompts"));
446    }
447
448    #[test]
449    fn prompt_messages_round_trip() {
450        let message = PromptMessage::user("Summarize order 7");
451        let json = message.to_json();
452
453        assert_eq!(json.get("role").unwrap().as_str(), Some("user"));
454        assert_eq!(json.get("content.type").unwrap().as_str(), Some("text"));
455        assert_eq!(PromptMessage::from_json(&json).unwrap(), message);
456    }
457
458    #[test]
459    fn the_protocol_version_is_the_one_this_crate_claims() {
460        assert_eq!(PROTOCOL_VERSION, "2025-06-18");
461        assert_eq!(SUPPORTED_VERSIONS[0], PROTOCOL_VERSION);
462    }
463}