use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: &str = "2024-11-05";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpTool {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(
default = "default_input_schema",
rename = "inputSchema",
alias = "input_schema"
)]
pub input_schema: serde_json::Value,
#[serde(default)]
pub annotations: Option<McpToolAnnotations>,
}
fn default_input_schema() -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct McpToolAnnotations {
#[serde(default)]
pub destructive_hint: bool,
#[serde(default)]
pub side_effects_hint: bool,
#[serde(default)]
pub read_only_hint: bool,
#[serde(default)]
pub execution_time_hint: Option<ExecutionTimeHint>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionTimeHint {
Fast,
Medium,
Slow,
}
impl McpTool {
pub fn requires_approval(&self) -> bool {
self.annotations
.as_ref()
.map(|a| a.destructive_hint)
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpRequest {
pub jsonrpc: String,
pub id: u64,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
impl McpRequest {
pub fn new(id: u64, method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id,
method: method.into(),
params,
}
}
pub fn initialize(id: u64) -> Self {
Self::new(
id,
"initialize",
Some(serde_json::json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {
"roots": { "listChanged": false },
"sampling": {}
},
"clientInfo": {
"name": "ironclaw",
"version": env!("CARGO_PKG_VERSION")
}
})),
)
}
pub fn initialized_notification() -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: 0, method: "notifications/initialized".to_string(),
params: None,
}
}
pub fn list_tools(id: u64) -> Self {
Self::new(id, "tools/list", None)
}
pub fn call_tool(id: u64, name: &str, arguments: serde_json::Value) -> Self {
Self::new(
id,
"tools/call",
Some(serde_json::json!({
"name": name,
"arguments": arguments
})),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpResponse {
pub jsonrpc: String,
pub id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<McpError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InitializeResult {
#[serde(rename = "protocolVersion")]
pub protocol_version: Option<String>,
#[serde(default)]
pub capabilities: ServerCapabilities,
#[serde(rename = "serverInfo")]
pub server_info: Option<ServerInfo>,
pub instructions: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServerCapabilities {
#[serde(default)]
pub tools: Option<ToolsCapability>,
#[serde(default)]
pub resources: Option<ResourcesCapability>,
#[serde(default)]
pub prompts: Option<PromptsCapability>,
#[serde(default)]
pub logging: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolsCapability {
#[serde(rename = "listChanged", default)]
pub list_changed: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ResourcesCapability {
#[serde(default)]
pub subscribe: bool,
#[serde(rename = "listChanged", default)]
pub list_changed: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PromptsCapability {
#[serde(rename = "listChanged", default)]
pub list_changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub name: String,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListToolsResult {
pub tools: Vec<McpTool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallToolResult {
pub content: Vec<ContentBlock>,
#[serde(default)]
pub is_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "image")]
Image { data: String, mime_type: String },
#[serde(rename = "resource")]
Resource {
uri: String,
mime_type: Option<String>,
text: Option<String>,
},
}
impl ContentBlock {
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text { text } => Some(text),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mcp_tool_deserialize_camel_case_input_schema() {
let json = serde_json::json!({
"name": "list_issues",
"description": "List GitHub issues",
"inputSchema": {
"type": "object",
"properties": {
"owner": { "type": "string" },
"repo": { "type": "string" }
},
"required": ["owner", "repo"]
}
});
let tool: McpTool = serde_json::from_value(json).expect("deserialize McpTool");
assert_eq!(tool.name, "list_issues");
assert_eq!(tool.description, "List GitHub issues");
let props = tool.input_schema.get("properties").expect("has properties");
assert!(props.get("owner").is_some());
assert!(props.get("repo").is_some());
}
#[test]
fn test_mcp_tool_deserialize_snake_case_alias() {
let json = serde_json::json!({
"name": "search",
"description": "Search",
"input_schema": {
"type": "object",
"properties": {
"query": { "type": "string" }
}
}
});
let tool: McpTool = serde_json::from_value(json).expect("deserialize McpTool");
let props = tool.input_schema.get("properties").expect("has properties");
assert!(props.get("query").is_some());
}
#[test]
fn test_mcp_tool_missing_schema_gets_default() {
let json = serde_json::json!({
"name": "ping",
"description": "Ping"
});
let tool: McpTool = serde_json::from_value(json).expect("deserialize McpTool");
assert_eq!(tool.input_schema["type"], "object");
assert!(tool.input_schema["properties"].is_object());
}
#[test]
fn test_mcp_tool_roundtrip_preserves_schema() {
let server_response = serde_json::json!({
"tools": [{
"name": "github-copilot_list_issues",
"description": "List issues for a repository",
"inputSchema": {
"type": "object",
"properties": {
"owner": { "type": "string", "description": "Repository owner" },
"repo": { "type": "string", "description": "Repository name" },
"state": { "type": "string", "enum": ["open", "closed", "all"] }
},
"required": ["owner", "repo"]
}
}]
});
let result: ListToolsResult =
serde_json::from_value(server_response).expect("deserialize ListToolsResult");
assert_eq!(result.tools.len(), 1);
let tool = &result.tools[0];
assert_eq!(tool.name, "github-copilot_list_issues");
let required = tool.input_schema.get("required").expect("has required");
assert!(required.as_array().expect("is array").len() == 2);
}
}