/ src / rmcp_server.rs
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  }