mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
- Fix MCP tool schema deserialization: rename input_schema to match protocol's camelCase inputSchema, so models receive actual parameter schemas instead of empty defaults - Fix conversation history: add tool_calls field to ChatMessage and include assistant message with tool_calls before tool results, as required by OpenAI-compatible APIs - Fix approval loop: pass resume_after_tool flag to run_agentic_loop so the "force tool use" heuristic doesn't re-trigger after approval - Fix shutdown: add Submission::Quit, Ctrl+C signal handler, and graceful shutdown flow - Fix MCP activate button: auto-attempt auth flow when activation fails due to missing authentication - Add inline approval cards in chat via SSE ApprovalNeeded events - Add markdown rendering in chat (marked.js) with proper streaming - Add structured fields to log entries (key=value pairs from tracing) - Collapse log entries to single line with click-to-expand Co-Authored-By: Claude Opus 4.6 <[email protected]>
385 lines
11 KiB
Rust
385 lines
11 KiB
Rust
//! MCP protocol types.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// MCP protocol version.
|
|
pub const PROTOCOL_VERSION: &str = "2024-11-05";
|
|
|
|
/// An MCP tool definition.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct McpTool {
|
|
/// Tool name.
|
|
pub name: String,
|
|
/// Tool description.
|
|
#[serde(default)]
|
|
pub description: String,
|
|
/// JSON Schema for input parameters.
|
|
/// Defaults to empty object schema if not provided.
|
|
/// MCP protocol uses camelCase `inputSchema`.
|
|
#[serde(
|
|
default = "default_input_schema",
|
|
rename = "inputSchema",
|
|
alias = "input_schema"
|
|
)]
|
|
pub input_schema: serde_json::Value,
|
|
/// Optional annotations from the MCP server.
|
|
#[serde(default)]
|
|
pub annotations: Option<McpToolAnnotations>,
|
|
}
|
|
|
|
/// Default input schema (empty object).
|
|
fn default_input_schema() -> serde_json::Value {
|
|
serde_json::json!({"type": "object", "properties": {}})
|
|
}
|
|
|
|
/// Annotations for an MCP tool that provide hints about its behavior.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct McpToolAnnotations {
|
|
/// Hint that this tool performs destructive operations that cannot be undone.
|
|
/// Tools with this hint set to true should require user approval before execution.
|
|
#[serde(default)]
|
|
pub destructive_hint: bool,
|
|
|
|
/// Hint that this tool may have side effects beyond its return value.
|
|
#[serde(default)]
|
|
pub side_effects_hint: bool,
|
|
|
|
/// Hint that this tool performs read-only operations.
|
|
#[serde(default)]
|
|
pub read_only_hint: bool,
|
|
|
|
/// Hint about the expected execution time category.
|
|
#[serde(default)]
|
|
pub execution_time_hint: Option<ExecutionTimeHint>,
|
|
}
|
|
|
|
/// Hint about how long a tool typically takes to execute.
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ExecutionTimeHint {
|
|
/// Typically completes in under 1 second.
|
|
Fast,
|
|
/// Typically completes in 1-10 seconds.
|
|
Medium,
|
|
/// Typically completes in more than 10 seconds.
|
|
Slow,
|
|
}
|
|
|
|
impl McpTool {
|
|
/// Check if this tool requires user approval based on its annotations.
|
|
pub fn requires_approval(&self) -> bool {
|
|
self.annotations
|
|
.as_ref()
|
|
.map(|a| a.destructive_hint)
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
/// Request to an MCP server.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct McpRequest {
|
|
/// JSON-RPC version.
|
|
pub jsonrpc: String,
|
|
/// Request ID.
|
|
pub id: u64,
|
|
/// Method name.
|
|
pub method: String,
|
|
/// Request parameters.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub params: Option<serde_json::Value>,
|
|
}
|
|
|
|
impl McpRequest {
|
|
/// Create a new MCP request.
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// Create an initialize request.
|
|
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")
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
|
|
/// Create an initialized notification (sent after initialize).
|
|
pub fn initialized_notification() -> Self {
|
|
Self {
|
|
jsonrpc: "2.0".to_string(),
|
|
id: 0, // Notifications don't have IDs, but we need one for the struct
|
|
method: "notifications/initialized".to_string(),
|
|
params: None,
|
|
}
|
|
}
|
|
|
|
/// Create a tools/list request.
|
|
pub fn list_tools(id: u64) -> Self {
|
|
Self::new(id, "tools/list", None)
|
|
}
|
|
|
|
/// Create a tools/call request.
|
|
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
|
|
})),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Response from an MCP server.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct McpResponse {
|
|
/// JSON-RPC version.
|
|
pub jsonrpc: String,
|
|
/// Request ID.
|
|
pub id: u64,
|
|
/// Result (on success).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub result: Option<serde_json::Value>,
|
|
/// Error (on failure).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<McpError>,
|
|
}
|
|
|
|
/// MCP error.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct McpError {
|
|
/// Error code.
|
|
pub code: i32,
|
|
/// Error message.
|
|
pub message: String,
|
|
/// Additional data.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub data: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Result of the initialize handshake.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct InitializeResult {
|
|
/// Protocol version supported by the server.
|
|
#[serde(rename = "protocolVersion")]
|
|
pub protocol_version: Option<String>,
|
|
|
|
/// Server capabilities.
|
|
#[serde(default)]
|
|
pub capabilities: ServerCapabilities,
|
|
|
|
/// Server information.
|
|
#[serde(rename = "serverInfo")]
|
|
pub server_info: Option<ServerInfo>,
|
|
|
|
/// Instructions for using this server.
|
|
pub instructions: Option<String>,
|
|
}
|
|
|
|
/// Server capabilities advertised during initialization.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct ServerCapabilities {
|
|
/// Tool capabilities.
|
|
#[serde(default)]
|
|
pub tools: Option<ToolsCapability>,
|
|
|
|
/// Resource capabilities.
|
|
#[serde(default)]
|
|
pub resources: Option<ResourcesCapability>,
|
|
|
|
/// Prompt capabilities.
|
|
#[serde(default)]
|
|
pub prompts: Option<PromptsCapability>,
|
|
|
|
/// Logging capabilities.
|
|
#[serde(default)]
|
|
pub logging: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Tool-related capabilities.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct ToolsCapability {
|
|
/// Whether the tool list can change.
|
|
#[serde(rename = "listChanged", default)]
|
|
pub list_changed: bool,
|
|
}
|
|
|
|
/// Resource-related capabilities.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct ResourcesCapability {
|
|
/// Whether subscriptions are supported.
|
|
#[serde(default)]
|
|
pub subscribe: bool,
|
|
|
|
/// Whether the resource list can change.
|
|
#[serde(rename = "listChanged", default)]
|
|
pub list_changed: bool,
|
|
}
|
|
|
|
/// Prompt-related capabilities.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct PromptsCapability {
|
|
/// Whether the prompt list can change.
|
|
#[serde(rename = "listChanged", default)]
|
|
pub list_changed: bool,
|
|
}
|
|
|
|
/// Server information.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ServerInfo {
|
|
/// Server name.
|
|
pub name: String,
|
|
|
|
/// Server version.
|
|
pub version: Option<String>,
|
|
}
|
|
|
|
/// Result of listing tools.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ListToolsResult {
|
|
pub tools: Vec<McpTool>,
|
|
}
|
|
|
|
/// Result of calling a tool.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CallToolResult {
|
|
pub content: Vec<ContentBlock>,
|
|
#[serde(default)]
|
|
pub is_error: bool,
|
|
}
|
|
|
|
/// Content block in a tool result.
|
|
#[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 {
|
|
/// Get text content if this is a text block.
|
|
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() {
|
|
// MCP protocol uses camelCase "inputSchema"
|
|
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");
|
|
|
|
// The schema must have the properties, not the empty default
|
|
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() {
|
|
// Also accept snake_case "input_schema" for flexibility
|
|
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() {
|
|
// Simulate what list_tools returns from a real MCP server
|
|
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);
|
|
}
|
|
}
|