canvas_desktop/
communitas.rs

1//! Simplified Communitas MCP client for the desktop application.
2//!
3//! This module provides a minimal client to connect to a Communitas MCP server
4//! and fetch scene documents. It is designed for startup initialization and
5//! does not include the full retry/bridge infrastructure from canvas-server.
6
7use 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/// Errors that can occur when talking to the Communitas MCP server.
20#[derive(Debug, Error)]
21pub enum DesktopCommunitasError {
22    /// The MCP base URL provided is invalid.
23    #[error("invalid Communitas MCP URL: {0}")]
24    InvalidUrl(String),
25    /// HTTP layer failed (connection, timeout, etc.).
26    #[error("Communitas MCP HTTP request failed: {0}")]
27    Http(#[from] reqwest::Error),
28    /// JSON parsing failed.
29    #[error("failed to parse Communitas MCP payload: {0}")]
30    Json(#[from] serde_json::Error),
31    /// The server returned an RPC error.
32    #[error("Communitas MCP RPC error {code}: {message}")]
33    Rpc {
34        /// Error code defined by MCP.
35        code: i32,
36        /// Human readable error message.
37        message: String,
38    },
39    /// The RPC response did not match the expected structure.
40    #[error("unexpected Communitas MCP response: {0}")]
41    UnexpectedResponse(String),
42    /// Scene conversion failed.
43    #[error("scene conversion failed: {0}")]
44    SceneConversion(String),
45}
46
47/// Minimal Communitas MCP client for desktop scene fetching.
48#[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    /// Create a new Communitas MCP client.
61    ///
62    /// `base_url` may be either the MCP endpoint itself (`https://host:3040/mcp`)
63    /// or just the host (in which case `/mcp` is appended automatically).
64    ///
65    /// # Errors
66    ///
67    /// Returns [`DesktopCommunitasError::InvalidUrl`] if the URL is malformed.
68    /// Returns [`DesktopCommunitasError::Http`] if the HTTP client fails to build.
69    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            // Disable proxy detection to avoid macOS system-configuration panic
80            .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    /// Perform MCP initialize handshake.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the HTTP request fails or the server returns an error.
97    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    /// Authenticate using a delegate token issued by Communitas.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if authentication fails.
116    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    /// Fetch the scene document for a session.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the fetch fails or the response cannot be parsed.
127    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    /// Call an MCP tool with optional arguments.
143    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        // Try direct scene field
157        if let Some(scene) = value.get("scene") {
158            return Ok(serde_json::from_value(scene.clone())?);
159        }
160
161        // Try MCP content array format
162        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/// Initialize result returned by Communitas MCP.
258#[derive(Debug, Clone, Deserialize)]
259pub struct InitializeResult {
260    /// Protocol version negotiated.
261    pub protocol_version: String,
262    /// Server capabilities.
263    pub capabilities: ServerCapabilities,
264    /// Server info.
265    pub server_info: ServerInfo,
266}
267
268/// Server capabilities from initialization.
269#[derive(Debug, Clone, Deserialize)]
270pub struct ServerCapabilities {
271    /// Tools capability.
272    #[serde(default)]
273    pub tools: Option<ToolsCapability>,
274}
275
276/// Tools capability descriptor.
277#[derive(Debug, Clone, Deserialize)]
278pub struct ToolsCapability {
279    /// Whether tool list changes are reported.
280    #[serde(default)]
281    pub list_changed: bool,
282}
283
284/// Server information.
285#[derive(Debug, Clone, Deserialize)]
286pub struct ServerInfo {
287    /// Server name.
288    pub name: String,
289    /// Server version.
290    pub version: String,
291}