Skip to main content

llm_kernel/mcp/
schema.rs

1//! Tool, resource, and prompt schema definitions for MCP.
2//!
3//! Field names follow the Model Context Protocol wire format (camelCase for
4//! `inputSchema` / `mimeType`), so the serialized JSON is what MCP clients
5//! expect from `tools/list`, `resources/list`, and `prompts/list`.
6
7use serde::{Deserialize, Serialize};
8
9/// Describes an MCP tool that an AI agent can invoke.
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct ToolDescription {
12    /// The tool name (unique within the server).
13    pub name: String,
14    /// Human-readable description of what the tool does.
15    pub description: String,
16    /// JSON Schema describing the tool's input parameters.
17    ///
18    /// Serialized as `inputSchema` per the MCP wire format.
19    #[serde(rename = "inputSchema")]
20    pub input_schema: serde_json::Value,
21}
22
23/// Describes an MCP resource that an AI agent can read.
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct ResourceDescription {
26    /// The resource URI (e.g. "docs://project/README.md").
27    pub uri: String,
28    /// Human-readable name.
29    pub name: String,
30    /// Optional description.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub description: Option<String>,
33    /// MIME type (e.g. "text/markdown").
34    ///
35    /// Serialized as `mimeType` per the MCP wire format.
36    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
37    pub mime_type: Option<String>,
38}
39
40/// A single argument accepted by an MCP prompt.
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct PromptArgument {
43    /// Argument name.
44    pub name: String,
45    /// Human-readable description of the argument.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub description: Option<String>,
48    /// Whether the argument must be supplied.
49    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
50    pub required: bool,
51    /// Argument value type (e.g. `"string"`, `"number"`, `"array"`), per the
52    /// typed prompt arguments in the 2026-07-28 server-concepts schema.
53    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
54    pub arg_type: Option<String>,
55}
56
57/// Describes an MCP prompt (a reusable, parameterized message template) that a
58/// client can list via `prompts/list` and render via `prompts/get`.
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
60pub struct PromptDescription {
61    /// The prompt name (unique within the server).
62    pub name: String,
63    /// Human-readable description of what the prompt is for.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub description: Option<String>,
66    /// Arguments the prompt accepts (used to fill the template).
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub arguments: Vec<PromptArgument>,
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn tool_description_serializes_camelcase_input_schema() {
77        let tool = ToolDescription {
78            name: "search".into(),
79            description: "Search documents".into(),
80            input_schema: serde_json::json!({"type": "object"}),
81        };
82        let json = serde_json::to_value(&tool).unwrap();
83        assert!(json.get("inputSchema").is_some(), "expected camelCase key");
84        assert!(json.get("input_schema").is_none());
85    }
86
87    #[test]
88    fn resource_description_serializes_camelcase_mime_type() {
89        let res = ResourceDescription {
90            uri: "docs://readme".into(),
91            name: "README".into(),
92            description: Some("Project readme".into()),
93            mime_type: Some("text/markdown".into()),
94        };
95        let json = serde_json::to_value(&res).unwrap();
96        assert_eq!(json["mimeType"], "text/markdown");
97        assert!(json.get("mime_type").is_none());
98    }
99
100    #[test]
101    fn resource_description_roundtrip() {
102        let res = ResourceDescription {
103            uri: "docs://readme".into(),
104            name: "README".into(),
105            description: Some("Project readme".into()),
106            mime_type: Some("text/markdown".into()),
107        };
108        let json = serde_json::to_string(&res).unwrap();
109        let back: ResourceDescription = serde_json::from_str(&json).unwrap();
110        assert_eq!(back.uri, "docs://readme");
111        assert_eq!(back.mime_type.as_deref(), Some("text/markdown"));
112    }
113
114    #[test]
115    fn prompt_description_serializes() {
116        let prompt = PromptDescription {
117            name: "summarize".into(),
118            description: Some("Summarize a document".into()),
119            arguments: vec![PromptArgument {
120                name: "text".into(),
121                description: Some("The text to summarize".into()),
122                required: true,
123                arg_type: Some("string".into()),
124            }],
125        };
126        let json = serde_json::to_value(&prompt).unwrap();
127        assert_eq!(json["name"], "summarize");
128        assert_eq!(json["arguments"][0]["name"], "text");
129        assert_eq!(json["arguments"][0]["required"], true);
130        assert_eq!(json["arguments"][0]["type"], "string");
131    }
132
133    #[test]
134    fn prompt_argument_omits_false_required() {
135        let arg = PromptArgument {
136            name: "opt".into(),
137            description: None,
138            required: false,
139            arg_type: None,
140        };
141        let json = serde_json::to_value(&arg).unwrap();
142        assert!(json.get("required").is_none(), "false required is omitted");
143        assert!(json.get("description").is_none());
144        assert!(json.get("type").is_none(), "untyped argument omits type");
145    }
146}