Skip to main content

raps_cli/mcp/
server.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! MCP Server implementation for RAPS
5//!
6//! Exposes APS API functionality as MCP tools for AI assistants.
7//! Tool implementations are split across sibling modules:
8//! - `tools_oss`   – Auth, OSS bucket/object, and translation tools
9//! - `tools_dm`    – Hub, project, folder, item, and template tools
10//! - `tools_admin` – Admin bulk ops, user listing, portfolio reports
11//! - `tools_acc`   – Issues, RFIs, assets, submittals, checklists
12//! - `tools_misc`  – Custom API, webhooks, design automation, reality capture
13//! - `dispatch`    – Tool dispatch (routes tool name → handler)
14//! - `definitions` – Tool schema definitions (`get_tools`)
15
16use rmcp::{ServerHandler, ServiceExt, model::*, transport::stdio};
17use serde_json::{Map, Value};
18use std::sync::Arc;
19use tokio::sync::RwLock;
20
21use raps_acc::{
22    AccClient, IssuesClient, RfiClient, admin::AccountAdminClient,
23    permissions::FolderPermissionsClient, users::ProjectUsersClient,
24};
25use raps_da::DesignAutomationClient;
26use raps_derivative::DerivativeClient;
27use raps_dm::DataManagementClient;
28use raps_kernel::auth::AuthClient;
29use raps_kernel::config::Config;
30use raps_kernel::http::HttpClientConfig;
31use raps_oss::OssClient;
32use raps_reality::RealityCaptureClient;
33use raps_webhooks::WebhooksClient;
34
35use super::definitions::get_tools;
36
37/// Default concurrency for bulk MCP operations.
38pub(crate) const MCP_BULK_CONCURRENCY: usize = 10;
39
40/// Headers that should be stripped from API responses before returning to AI.
41pub(crate) const SENSITIVE_HEADERS: &[&str] = &[
42    "set-cookie",
43    "www-authenticate",
44    "authorization",
45    "proxy-authorization",
46    "cookie",
47];
48
49/// RAPS MCP Server
50///
51/// Provides AI assistants with direct access to Autodesk Platform Services.
52#[derive(Clone)]
53pub struct RapsServer {
54    pub(crate) config: Arc<Config>,
55    pub(crate) http_config: HttpClientConfig,
56    // Cached clients (Clone-able)
57    auth_client: Arc<RwLock<Option<AuthClient>>>,
58    oss_client: Arc<RwLock<Option<OssClient>>>,
59    derivative_client: Arc<RwLock<Option<DerivativeClient>>>,
60    dm_client: Arc<RwLock<Option<DataManagementClient>>>,
61    // Note: ACC/Admin clients are created on-demand (not cached) as they don't implement Clone
62}
63
64impl RapsServer {
65    /// Create a new RAPS MCP Server
66    pub fn new() -> Result<Self, anyhow::Error> {
67        let config = Config::from_env_lenient()?;
68        let http_config = HttpClientConfig::default();
69
70        Ok(Self {
71            config: Arc::new(config),
72            http_config,
73            auth_client: Arc::new(RwLock::new(None)),
74            oss_client: Arc::new(RwLock::new(None)),
75            derivative_client: Arc::new(RwLock::new(None)),
76            dm_client: Arc::new(RwLock::new(None)),
77        })
78    }
79
80    /// Accessor for config (used by sibling tool modules).
81    pub(crate) fn config(&self) -> &Config {
82        &self.config
83    }
84
85    /// Accessor for HTTP client config (used by sibling tool modules).
86    pub(crate) fn http_config(&self) -> &HttpClientConfig {
87        &self.http_config
88    }
89
90    // ========================================================================
91    // Client factories (double-checked locking for cached clients)
92    // ========================================================================
93
94    pub(crate) async fn get_auth_client(&self) -> AuthClient {
95        if let Some(client) = self.auth_client.read().await.as_ref() {
96            return client.clone();
97        }
98
99        let mut guard = self.auth_client.write().await;
100        if guard.is_none() {
101            *guard = Some(AuthClient::new_with_http_config(
102                (*self.config).clone(),
103                self.http_config.clone(),
104            ));
105        }
106        guard
107            .as_ref()
108            .expect("client was just initialized above")
109            .clone()
110    }
111
112    pub(crate) async fn get_oss_client(&self) -> OssClient {
113        if let Some(client) = self.oss_client.read().await.as_ref() {
114            return client.clone();
115        }
116
117        let auth = self.get_auth_client().await;
118        let mut guard = self.oss_client.write().await;
119        if guard.is_none() {
120            *guard = Some(OssClient::new_with_http_config(
121                (*self.config).clone(),
122                auth,
123                self.http_config.clone(),
124            ));
125        }
126        guard
127            .as_ref()
128            .expect("client was just initialized above")
129            .clone()
130    }
131
132    pub(crate) async fn get_derivative_client(&self) -> DerivativeClient {
133        if let Some(client) = self.derivative_client.read().await.as_ref() {
134            return client.clone();
135        }
136
137        let auth = self.get_auth_client().await;
138        let mut guard = self.derivative_client.write().await;
139        if guard.is_none() {
140            *guard = Some(DerivativeClient::new_with_http_config(
141                (*self.config).clone(),
142                auth,
143                self.http_config.clone(),
144            ));
145        }
146        guard
147            .as_ref()
148            .expect("client was just initialized above")
149            .clone()
150    }
151
152    pub(crate) async fn get_dm_client(&self) -> DataManagementClient {
153        if let Some(client) = self.dm_client.read().await.as_ref() {
154            return client.clone();
155        }
156
157        let auth = self.get_auth_client().await;
158        let mut guard = self.dm_client.write().await;
159        if guard.is_none() {
160            *guard = Some(DataManagementClient::new_with_http_config(
161                (*self.config).clone(),
162                auth,
163                self.http_config.clone(),
164            ));
165        }
166        guard
167            .as_ref()
168            .expect("client was just initialized above")
169            .clone()
170    }
171
172    // On-demand clients (not cached, created fresh each time)
173
174    pub(crate) async fn get_admin_client(&self) -> AccountAdminClient {
175        let auth = self.get_auth_client().await;
176        AccountAdminClient::new_with_http_config(
177            (*self.config).clone(),
178            auth,
179            self.http_config.clone(),
180        )
181    }
182
183    pub(crate) async fn get_users_client(&self) -> ProjectUsersClient {
184        let auth = self.get_auth_client().await;
185        ProjectUsersClient::new_with_http_config(
186            (*self.config).clone(),
187            auth,
188            self.http_config.clone(),
189        )
190    }
191
192    pub(crate) async fn get_issues_client(&self) -> IssuesClient {
193        let auth = self.get_auth_client().await;
194        IssuesClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
195    }
196
197    pub(crate) async fn get_rfi_client(&self) -> RfiClient {
198        let auth = self.get_auth_client().await;
199        RfiClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
200    }
201
202    pub(crate) async fn get_acc_client(&self) -> AccClient {
203        let auth = self.get_auth_client().await;
204        AccClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
205    }
206
207    pub(crate) async fn get_permissions_client(&self) -> FolderPermissionsClient {
208        let auth = self.get_auth_client().await;
209        FolderPermissionsClient::new_with_http_config(
210            (*self.config).clone(),
211            auth,
212            self.http_config.clone(),
213        )
214    }
215
216    pub(crate) async fn get_webhooks_client(&self) -> WebhooksClient {
217        let auth = self.get_auth_client().await;
218        WebhooksClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
219    }
220
221    pub(crate) async fn get_da_client(&self) -> DesignAutomationClient {
222        let auth = self.get_auth_client().await;
223        DesignAutomationClient::new_with_http_config(
224            (*self.config).clone(),
225            auth,
226            self.http_config.clone(),
227        )
228    }
229
230    pub(crate) async fn get_reality_client(&self) -> RealityCaptureClient {
231        let auth = self.get_auth_client().await;
232        raps_reality::RealityCaptureClient::new_with_http_config(
233            (*self.config).clone(),
234            auth,
235            self.http_config.clone(),
236        )
237    }
238
239    // ========================================================================
240    // Utility helpers
241    // ========================================================================
242
243    pub(crate) fn clamp_limit(limit: Option<usize>, default: usize, max: usize) -> usize {
244        let limit = limit.unwrap_or(default).max(1);
245        limit.min(max)
246    }
247
248    pub(crate) fn required_arg(args: &Map<String, Value>, key: &str) -> Result<String, String> {
249        args.get(key)
250            .and_then(|v| v.as_str())
251            .map(str::trim)
252            .filter(|v| !v.is_empty())
253            .map(|v| v.to_string())
254            .ok_or_else(|| format!("Missing required argument '{}'.", key))
255    }
256
257    pub(crate) fn optional_arg(args: &Map<String, Value>, key: &str) -> Option<String> {
258        args.get(key)
259            .and_then(|v| v.as_str())
260            .map(str::trim)
261            .filter(|v| !v.is_empty())
262            .map(|v| v.to_string())
263    }
264
265    /// Validate that a URN looks like a base64-encoded APS URN.
266    pub(crate) fn validate_urn(urn: &str) -> Result<(), String> {
267        if urn.len() < 10 {
268            return Err("URN is too short — expected a base64-encoded APS URN.".to_string());
269        }
270        if urn.contains(' ') {
271            return Err("URN must not contain spaces.".to_string());
272        }
273        Ok(())
274    }
275
276    /// Validate that an ID looks like a GUID (with optional prefix like `b.`).
277    #[allow(dead_code)]
278    pub(crate) fn validate_id(value: &str, label: &str) -> Result<(), String> {
279        // Allow prefixed IDs like "b.abc-123" or plain GUIDs
280        let id_part = value.rsplit('.').next().unwrap_or(value);
281        if id_part.len() < 8 {
282            return Err(format!(
283                "{} '{}' looks too short — expected a GUID or APS ID.",
284                label, value
285            ));
286        }
287        Ok(())
288    }
289}
290
291// ============================================================================
292// Free functions
293// ============================================================================
294
295/// Human-readable file-size formatting.
296pub(crate) fn format_size(bytes: u64) -> String {
297    const KB: u64 = 1024;
298    const MB: u64 = KB * 1024;
299    const GB: u64 = MB * 1024;
300
301    if bytes >= GB {
302        format!("{:.1} GB", bytes as f64 / GB as f64)
303    } else if bytes >= MB {
304        format!("{:.1} MB", bytes as f64 / MB as f64)
305    } else if bytes >= KB {
306        format!("{:.1} KB", bytes as f64 / KB as f64)
307    } else {
308        format!("{} bytes", bytes)
309    }
310}
311
312/// Validate that a file path is not pointing at a sensitive system location.
313/// Returns Ok(()) if safe, Err(message) if the path should be rejected.
314pub(crate) fn validate_file_path(path: &std::path::Path) -> Result<(), String> {
315    let path_str = path.to_string_lossy().to_lowercase();
316
317    // Block well-known sensitive paths
318    let blocked_patterns = [
319        ".ssh",
320        ".gnupg",
321        ".aws/credentials",
322        ".env",
323        "id_rsa",
324        "id_ed25519",
325        "authorized_keys",
326        "known_hosts",
327        "/etc/shadow",
328        "/etc/passwd",
329        "/etc/cron",
330        "credentials.json",
331        "secrets.json",
332        "token.json",
333    ];
334
335    for pattern in &blocked_patterns {
336        if path_str.contains(pattern) {
337            return Err(format!(
338                "Error: Path '{}' targets a sensitive location (matched '{}').\n\
339                 MCP tools cannot read/write security-sensitive files.",
340                path.display(),
341                pattern
342            ));
343        }
344    }
345
346    Ok(())
347}
348
349// ============================================================================
350// ServerHandler implementation
351// ============================================================================
352
353impl ServerHandler for RapsServer {
354    fn get_info(&self) -> ServerInfo {
355        ServerInfo {
356            instructions: Some(format!(
357                "RAPS MCP Server v{version} - Autodesk Platform Services CLI\n\n\
358                    Provides direct access to APS APIs:\n\
359                    * auth_* - Authentication (2-legged and 3-legged OAuth)\n\
360                    * bucket_*, object_* - OSS storage operations (incl. upload/download/copy)\n\
361                    * translate_* - CAD model translation\n\
362                    * hub_*, project_* - Data Management & Project Info\n\
363                    * folder_*, item_* - Folder and file management\n\
364                    * project_create, project_user_* - ACC Project Admin\n\
365                    * template_* - Project template management\n\
366                    * admin_* - Bulk account administration\n\
367                    * issue_*, rfi_* - ACC Issues and RFIs\n\
368                    * acc_* - ACC Assets, Submittals, Checklists\n\
369                    * da_* - Design Automation\n\
370                    * reality_* - Reality Capture / Photogrammetry\n\
371                    * webhook_* - Event subscriptions\n\
372                    * api_request - Custom APS API calls\n\
373                    * report_* - Portfolio reports\n\n\
374                    Set APS_CLIENT_ID and APS_CLIENT_SECRET env vars.\n\
375                    For 3-legged auth, run 'raps auth login' first.",
376                version = env!("CARGO_PKG_VERSION"),
377            )),
378            capabilities: ServerCapabilities::builder().enable_tools().build(),
379            ..Default::default()
380        }
381    }
382
383    async fn list_tools(
384        &self,
385        _request: Option<PaginatedRequestParam>,
386        _context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
387    ) -> Result<ListToolsResult, rmcp::ErrorData> {
388        Ok(ListToolsResult {
389            tools: get_tools(),
390            next_cursor: None,
391            meta: None,
392        })
393    }
394
395    async fn call_tool(
396        &self,
397        request: CallToolRequestParam,
398        _context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
399    ) -> Result<CallToolResult, rmcp::ErrorData> {
400        let result = self.dispatch_tool(&request.name, request.arguments).await;
401        Ok(result)
402    }
403}
404
405/// Run the MCP server using stdio transport
406pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
407    // logging::init() in main.rs already set up a global subscriber;
408    // no redundant init needed here.
409
410    let server = RapsServer::new()?;
411    let service = server.serve(stdio()).await?;
412    service.waiting().await?;
413    Ok(())
414}