rmcp_server.rs
1 use std::sync::Arc; 2 3 use radicle::prelude::RepoId; 4 use radicle::profile::Profile; 5 use rmcp::{RoleServer, ServerHandler, model::*, service::RequestContext, tool, tool_router}; 6 7 // Needed for list_tools signature 8 use core::future::Future; 9 10 // Bring in the Parameters wrapper so rmcp-macros can infer input schemas automatically. 11 use rmcp::handler::server::wrapper::Parameters; 12 13 // We'll derive JSON schema for typed Parameters via schemars. 14 use serde::{Deserialize, Serialize}; 15 // Use schemars re-exported by rmcp to avoid version mismatch 16 use rmcp::schemars; 17 18 // ==== Typed request structs for tool Parameters (derive schemars for JSON Schema) ==== 19 #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] 20 pub struct CreateIssueArgs { 21 /// Issue title 22 pub title: String, 23 /// Issue description 24 pub description: String, 25 } 26 27 #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] 28 pub struct AddCommentArgs { 29 /// Target issue ID (hex) 30 pub issue_id: String, 31 /// Comment text 32 pub body: String, 33 /// Optional comment ID to reply to 34 #[serde(skip_serializing_if = "Option::is_none")] 35 pub reply_to: Option<String>, 36 } 37 38 #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] 39 pub struct AddLabelArgs { 40 /// Target issue ID (hex) 41 pub issue_id: String, 42 /// Label to add 43 pub label: String, 44 } 45 46 #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] 47 pub struct EditIssueStatusArgs { 48 /// Target issue ID (hex) 49 pub issue_id: String, 50 /// New status: open | closed | solved 51 pub status: String, 52 } 53 54 #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] 55 pub struct ListCommentsArgs { 56 /// Target issue ID (hex) 57 pub issue_id: String, 58 } 59 60 /// The rmcp-native server wrapper. We'll gradually add tools using 61 /// `#[tool]` methods in the `#[tool_router]` impl and prompts if needed. 62 #[derive(Debug)] 63 pub struct RadicleServer { 64 pub profile: Arc<Profile>, 65 pub rid: RepoId, 66 } 67 68 impl RadicleServer { 69 pub fn new(profile: Arc<Profile>, rid: RepoId) -> Self { 70 Self { profile, rid } 71 } 72 } 73 74 impl Clone for RadicleServer { 75 fn clone(&self) -> Self { 76 // Shallow clone (Profile is in Arc) 77 Self { 78 profile: self.profile.clone(), 79 rid: self.rid, 80 } 81 } 82 } 83 84 impl ServerHandler for RadicleServer { 85 fn get_info(&self) -> ServerInfo { 86 ServerInfo { 87 // Align with existing MCP protocol version policy 88 protocol_version: ProtocolVersion::V_2024_11_05, 89 capabilities: ServerCapabilities::builder().enable_tools().build(), 90 server_info: Implementation::from_build_env(), 91 instructions: Some("Radicle MCP server (rmcp scaffold)".to_string()), 92 } 93 } 94 95 async fn initialize( 96 &self, 97 _request: InitializeRequestParam, 98 _context: RequestContext<RoleServer>, 99 ) -> Result<InitializeResult, rmcp::ErrorData> { 100 Ok(self.get_info()) 101 } 102 103 fn list_tools( 104 &self, 105 _request: Option<PaginatedRequestParam>, 106 _context: RequestContext<RoleServer>, 107 ) -> impl Future<Output = Result<ListToolsResult, ErrorData>> + Send + '_ { 108 std::future::ready(Ok(ListToolsResult { 109 meta: None, 110 next_cursor: None, 111 tools: Self::tool_router().list_all(), 112 })) 113 } 114 115 async fn call_tool( 116 &self, 117 request: CallToolRequestParam, 118 context: RequestContext<RoleServer>, 119 ) -> Result<CallToolResult, ErrorData> { 120 let tool_call_context = 121 rmcp::handler::server::tool::ToolCallContext::new(self, request, context); 122 Self::tool_router().call(tool_call_context).await 123 } 124 } 125 126 /// rmcp-native tools implemented directly on the server using macros. 127 /// We'll gradually port tools from the legacy adapter. First: `list_issues`. 128 #[tool_router] 129 impl RadicleServer { 130 /// Port of the legacy `list_issues` tool using rmcp macros. 131 /// This method takes no parameters and returns a JSON content payload 132 /// containing the same structure as `run_list_issues`. 133 #[tool( 134 name = "list_issues", 135 description = "List Radicle issues for the git repository at the server's configured base directory." 136 )] 137 fn list_issues(&self) -> Result<CallToolResult, rmcp::ErrorData> { 138 use crate::tools::list_issues::run_list_issues; 139 use rmcp::model::ErrorData; 140 // Execute the existing helper with our profile/repo context. 141 let res = run_list_issues(&self.profile, self.rid) 142 .map_err(|e| ErrorData::internal_error(format!("Failed to list issues: {e}"), None))?; 143 144 // Return as MCP content (single JSON block), matching prior behavior. 145 Ok(CallToolResult::success(vec![Content::json(&res)?])) 146 } 147 148 /// Create a new Radicle issue. 149 #[tool( 150 name = "create_issue", 151 description = "Create a new Radicle issue in the current repository." 152 )] 153 fn create_issue( 154 &self, 155 Parameters(args): Parameters<CreateIssueArgs>, 156 ) -> Result<CallToolResult, rmcp::ErrorData> { 157 use crate::tools::create_issue::run_create_issue; 158 use rmcp::model::ErrorData; 159 let res = run_create_issue(&self.profile, self.rid, &args.title, &args.description) 160 .map_err(|e| ErrorData::internal_error(format!("Failed to create issue: {e}"), None))?; 161 Ok(CallToolResult::success(vec![Content::json(&res)?])) 162 } 163 164 /// Add a comment to an issue (optionally in reply to a specific comment). 165 #[tool( 166 name = "add_comment", 167 description = "Add a comment to a Radicle issue in the current repository." 168 )] 169 fn add_comment( 170 &self, 171 Parameters(args): Parameters<AddCommentArgs>, 172 ) -> Result<CallToolResult, rmcp::ErrorData> { 173 use crate::tools::add_comment::run_add_comment; 174 use rmcp::model::ErrorData; 175 let reply_to_opt = args.reply_to.as_deref(); 176 let res = run_add_comment( 177 &self.profile, 178 self.rid, 179 &args.issue_id, 180 &args.body, 181 reply_to_opt, 182 ) 183 .map_err(|e| { 184 ErrorData::internal_error( 185 format!("Failed to add comment to issue '{}': {e}", args.issue_id), 186 None, 187 ) 188 })?; 189 Ok(CallToolResult::success(vec![Content::json(&res)?])) 190 } 191 192 /// Add a label to an existing issue. 193 #[tool( 194 name = "add_label", 195 description = "Add a label to a specific Radicle issue in the current repository." 196 )] 197 fn add_label( 198 &self, 199 Parameters(args): Parameters<AddLabelArgs>, 200 ) -> Result<CallToolResult, rmcp::ErrorData> { 201 use crate::tools::add_label::run_add_label; 202 use rmcp::model::ErrorData; 203 let res = 204 run_add_label(&self.profile, self.rid, &args.issue_id, &args.label).map_err(|e| { 205 ErrorData::internal_error( 206 format!("Failed to add label to issue '{}': {e}", args.issue_id), 207 None, 208 ) 209 })?; 210 Ok(CallToolResult::success(vec![Content::json(&res)?])) 211 } 212 213 /// Edit an issue's status: open | closed | solved. 214 #[tool( 215 name = "edit_issue_status", 216 description = "Edit a Radicle issue's status (open/closed/solved) in the current repository." 217 )] 218 fn edit_issue_status( 219 &self, 220 Parameters(args): Parameters<EditIssueStatusArgs>, 221 ) -> Result<CallToolResult, rmcp::ErrorData> { 222 use crate::tools::edit_issue_status::run_edit_issue_status; 223 use rmcp::model::ErrorData; 224 let res = run_edit_issue_status(&self.profile, self.rid, &args.issue_id, &args.status) 225 .map_err(|e| { 226 ErrorData::internal_error( 227 format!("Failed to edit issue status for '{}': {e}", args.issue_id), 228 None, 229 ) 230 })?; 231 Ok(CallToolResult::success(vec![Content::json(&res)?])) 232 } 233 234 /// List comments for a given issue. 235 #[tool( 236 name = "list_comments", 237 description = "List comments for a specific Radicle issue in the current repository." 238 )] 239 fn list_comments( 240 &self, 241 Parameters(args): Parameters<ListCommentsArgs>, 242 ) -> Result<CallToolResult, rmcp::ErrorData> { 243 use crate::tools::list_comments::run_list_comments; 244 use rmcp::model::ErrorData; 245 let res = run_list_comments(&self.profile, self.rid, &args.issue_id).map_err(|e| { 246 ErrorData::internal_error( 247 format!("Failed to list comments for issue '{}': {e}", args.issue_id), 248 None, 249 ) 250 })?; 251 Ok(CallToolResult::success(vec![Content::json(&res)?])) 252 } 253 } 254 255 #[cfg(test)] 256 mod router_schema_tests { 257 use super::*; 258 259 // This unit test verifies that the tool router exposes parameter schemas for 260 // tools that take Parameters<T>. Specifically, we check that the following 261 // tools have non-empty "properties" in their input schema: 262 // - create_issue 263 // - add_comment 264 // - add_label 265 // - edit_issue_status 266 // For the parameterless tool (list_issues), the schema should have an empty 267 // properties object. 268 #[test] 269 fn tool_router_exposes_parameters_for_parameterized_tools() { 270 // Acquire the statically generated tool router and list all tool defs. 271 let tools = RadicleServer::tool_router().list_all(); 272 273 // Index by name for easy assertions 274 let mut by_name = std::collections::HashMap::new(); 275 for t in tools { 276 by_name.insert(t.name.clone(), t); 277 } 278 279 // Helper to assert schema has non-empty properties 280 let assert_has_params = |tool_name: &str| { 281 let tool = by_name 282 .get(tool_name) 283 .unwrap_or_else(|| panic!("tool '{tool_name}' not found in router")); 284 let schema = &tool.input_schema; // Arc<Map<String, Value>> 285 let props = schema 286 .get("properties") 287 .and_then(|v| v.as_object()) 288 .unwrap_or_else(|| { 289 panic!("tool '{tool_name}' missing 'properties' in input_schema") 290 }); 291 assert!( 292 !props.is_empty(), 293 "tool '{tool_name}' should expose at least one parameter property" 294 ); 295 }; 296 297 assert_has_params("create_issue"); 298 assert_has_params("add_comment"); 299 assert_has_params("add_label"); 300 assert_has_params("edit_issue_status"); 301 302 // Optional sanity check: list_issues should have empty properties (no params) 303 let list_issues = by_name 304 .get("list_issues") 305 .expect("list_issues tool must exist"); 306 let props = list_issues 307 .input_schema 308 .get("properties") 309 .and_then(|v| v.as_object()) 310 .expect("list_issues should have a properties object in schema"); 311 assert!(props.is_empty(), "list_issues should have no parameters"); 312 } 313 }