1use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10use canvas_core::SceneDocument;
11use reqwest::Client;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use thiserror::Error;
15use url::Url;
16
17const JSONRPC_VERSION: &str = "2.0";
18
19#[derive(Debug, Error)]
21pub enum DesktopCommunitasError {
22 #[error("invalid Communitas MCP URL: {0}")]
24 InvalidUrl(String),
25 #[error("Communitas MCP HTTP request failed: {0}")]
27 Http(#[from] reqwest::Error),
28 #[error("failed to parse Communitas MCP payload: {0}")]
30 Json(#[from] serde_json::Error),
31 #[error("Communitas MCP RPC error {code}: {message}")]
33 Rpc {
34 code: i32,
36 message: String,
38 },
39 #[error("unexpected Communitas MCP response: {0}")]
41 UnexpectedResponse(String),
42 #[error("scene conversion failed: {0}")]
44 SceneConversion(String),
45}
46
47#[derive(Clone)]
49pub struct DesktopMcpClient {
50 inner: Arc<InnerClient>,
51}
52
53struct InnerClient {
54 http: Client,
55 endpoint: Url,
56 request_id: AtomicU64,
57}
58
59impl DesktopMcpClient {
60 pub fn new(base_url: &str) -> Result<Self, DesktopCommunitasError> {
70 let mut url =
71 Url::parse(base_url).map_err(|e| DesktopCommunitasError::InvalidUrl(e.to_string()))?;
72
73 if url.path().is_empty() || url.path() == "/" {
74 url.set_path("/mcp");
75 }
76
77 let http = Client::builder()
78 .user_agent("canvas-desktop (saorsa-canvas)")
79 .no_proxy()
81 .build()?;
82
83 Ok(Self {
84 inner: Arc::new(InnerClient {
85 http,
86 endpoint: url,
87 request_id: AtomicU64::new(1),
88 }),
89 })
90 }
91
92 pub async fn initialize(&self) -> Result<InitializeResult, DesktopCommunitasError> {
98 let params = InitializeParams {
99 protocol_version: "2024-11-05".to_string(),
100 capabilities: ClientCapabilities {},
101 client_info: ClientInfo {
102 name: "canvas-desktop".to_string(),
103 version: env!("CARGO_PKG_VERSION").to_string(),
104 },
105 };
106
107 self.send_rpc("initialize", Some(serde_json::to_value(params)?))
108 .await
109 }
110
111 pub async fn authenticate(&self, token: &str) -> Result<(), DesktopCommunitasError> {
117 self.call_tool("authenticate_token", Some(json!({ "token": token })))
118 .await?;
119 Ok(())
120 }
121
122 pub async fn get_scene(
128 &self,
129 session: Option<&str>,
130 ) -> Result<SceneDocument, DesktopCommunitasError> {
131 let session_id = session.unwrap_or("default");
132 let response = self
133 .call_tool(
134 "canvas_get_scene",
135 Some(json!({ "session_id": session_id })),
136 )
137 .await?;
138
139 Self::deserialize_scene(&response)
140 }
141
142 async fn call_tool(
144 &self,
145 name: &str,
146 arguments: Option<Value>,
147 ) -> Result<Value, DesktopCommunitasError> {
148 let params = json!({
149 "name": name,
150 "arguments": arguments.unwrap_or_else(|| json!({}))
151 });
152 self.send_rpc::<Value>("tools/call", Some(params)).await
153 }
154
155 fn deserialize_scene(value: &Value) -> Result<SceneDocument, DesktopCommunitasError> {
156 if let Some(scene) = value.get("scene") {
158 return Ok(serde_json::from_value(scene.clone())?);
159 }
160
161 if let Some(content) = value.get("content").and_then(Value::as_array) {
163 if let Some(first) = content.first() {
164 if let Some(text) = first.get("text").and_then(Value::as_str) {
165 return serde_json::from_str(text).map_err(DesktopCommunitasError::from);
166 }
167 }
168 }
169
170 Err(DesktopCommunitasError::UnexpectedResponse(
171 "response did not contain a scene document".to_string(),
172 ))
173 }
174
175 async fn send_rpc<T>(
176 &self,
177 method: &str,
178 params: Option<Value>,
179 ) -> Result<T, DesktopCommunitasError>
180 where
181 for<'de> T: Deserialize<'de>,
182 {
183 let id = self.inner.request_id.fetch_add(1, Ordering::Relaxed);
184 let request = JsonRpcRequest {
185 jsonrpc: JSONRPC_VERSION,
186 id,
187 method,
188 params,
189 };
190
191 let response = self
192 .inner
193 .http
194 .post(self.inner.endpoint.clone())
195 .json(&request)
196 .send()
197 .await?;
198
199 let rpc: JsonRpcResponse = response.json().await?;
200
201 if let Some(error) = rpc.error {
202 return Err(DesktopCommunitasError::Rpc {
203 code: error.code,
204 message: error.message,
205 });
206 }
207
208 let result = rpc
209 .result
210 .ok_or_else(|| DesktopCommunitasError::UnexpectedResponse("missing result".into()))?;
211
212 Ok(serde_json::from_value(result)?)
213 }
214}
215
216#[derive(Debug, Clone, Serialize)]
217struct JsonRpcRequest<'a> {
218 jsonrpc: &'a str,
219 id: u64,
220 method: &'a str,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 params: Option<Value>,
223}
224
225#[derive(Debug, Deserialize)]
226struct JsonRpcResponse {
227 #[allow(dead_code)]
228 jsonrpc: String,
229 #[serde(default)]
230 result: Option<Value>,
231 #[serde(default)]
232 error: Option<JsonRpcError>,
233}
234
235#[derive(Debug, Deserialize)]
236struct JsonRpcError {
237 code: i32,
238 message: String,
239}
240
241#[derive(Debug, Clone, Serialize)]
242struct InitializeParams {
243 protocol_version: String,
244 capabilities: ClientCapabilities,
245 client_info: ClientInfo,
246}
247
248#[derive(Debug, Clone, Serialize)]
249struct ClientCapabilities {}
250
251#[derive(Debug, Clone, Serialize)]
252struct ClientInfo {
253 name: String,
254 version: String,
255}
256
257#[derive(Debug, Clone, Deserialize)]
259pub struct InitializeResult {
260 pub protocol_version: String,
262 pub capabilities: ServerCapabilities,
264 pub server_info: ServerInfo,
266}
267
268#[derive(Debug, Clone, Deserialize)]
270pub struct ServerCapabilities {
271 #[serde(default)]
273 pub tools: Option<ToolsCapability>,
274}
275
276#[derive(Debug, Clone, Deserialize)]
278pub struct ToolsCapability {
279 #[serde(default)]
281 pub list_changed: bool,
282}
283
284#[derive(Debug, Clone, Deserialize)]
286pub struct ServerInfo {
287 pub name: String,
289 pub version: String,
291}