raps-cli 4.15.0

RAPS (rapeseed) - Rust Autodesk Platform Services CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
// SPDX-License-Identifier: Apache-2.0
// Copyright 2024-2025 Dmytro Yemelianov

//! MCP Server implementation for RAPS
//!
//! Exposes APS API functionality as MCP tools for AI assistants.
//! Tool implementations are split across sibling modules:
//! - `tools_oss`   – Auth, OSS bucket/object, and translation tools
//! - `tools_dm`    – Hub, project, folder, item, and template tools
//! - `tools_admin` – Admin bulk ops, user listing, portfolio reports
//! - `tools_acc`   – Issues, RFIs, assets, submittals, checklists
//! - `tools_misc`  – Custom API, webhooks, design automation, reality capture
//! - `dispatch`    – Tool dispatch (routes tool name → handler)
//! - `definitions` – Tool schema definitions (`get_tools`)

use rmcp::{ServerHandler, ServiceExt, model::*, transport::stdio};
use serde_json::{Map, Value};
use std::sync::Arc;
use tokio::sync::RwLock;

use raps_acc::{
    AccClient, IssuesClient, RfiClient, admin::AccountAdminClient,
    permissions::FolderPermissionsClient, users::ProjectUsersClient,
};
use raps_da::DesignAutomationClient;
use raps_derivative::DerivativeClient;
use raps_dm::DataManagementClient;
use raps_kernel::auth::AuthClient;
use raps_kernel::config::Config;
use raps_kernel::http::HttpClientConfig;
use raps_oss::OssClient;
use raps_reality::RealityCaptureClient;
use raps_webhooks::WebhooksClient;

use super::definitions::get_tools;

/// Default concurrency for bulk MCP operations.
pub(crate) const MCP_BULK_CONCURRENCY: usize = 10;

/// Headers that should be stripped from API responses before returning to AI.
pub(crate) const SENSITIVE_HEADERS: &[&str] = &[
    "set-cookie",
    "www-authenticate",
    "authorization",
    "proxy-authorization",
    "cookie",
];

/// RAPS MCP Server
///
/// Provides AI assistants with direct access to Autodesk Platform Services.
#[derive(Clone)]
pub struct RapsServer {
    pub(crate) config: Arc<Config>,
    pub(crate) http_config: HttpClientConfig,
    // Cached clients (Clone-able)
    auth_client: Arc<RwLock<Option<AuthClient>>>,
    oss_client: Arc<RwLock<Option<OssClient>>>,
    derivative_client: Arc<RwLock<Option<DerivativeClient>>>,
    dm_client: Arc<RwLock<Option<DataManagementClient>>>,
    // Note: ACC/Admin clients are created on-demand (not cached) as they don't implement Clone
}

impl RapsServer {
    /// Create a new RAPS MCP Server
    pub fn new() -> Result<Self, anyhow::Error> {
        let config = Config::from_env_lenient()?;
        let http_config = HttpClientConfig::default();

        Ok(Self {
            config: Arc::new(config),
            http_config,
            auth_client: Arc::new(RwLock::new(None)),
            oss_client: Arc::new(RwLock::new(None)),
            derivative_client: Arc::new(RwLock::new(None)),
            dm_client: Arc::new(RwLock::new(None)),
        })
    }

    /// Accessor for config (used by sibling tool modules).
    pub(crate) fn config(&self) -> &Config {
        &self.config
    }

    /// Accessor for HTTP client config (used by sibling tool modules).
    pub(crate) fn http_config(&self) -> &HttpClientConfig {
        &self.http_config
    }

    // ========================================================================
    // Client factories (double-checked locking for cached clients)
    // ========================================================================

    pub(crate) async fn get_auth_client(&self) -> AuthClient {
        if let Some(client) = self.auth_client.read().await.as_ref() {
            return client.clone();
        }

        let mut guard = self.auth_client.write().await;
        if guard.is_none() {
            *guard = Some(AuthClient::new_with_http_config(
                (*self.config).clone(),
                self.http_config.clone(),
            ));
        }
        guard
            .as_ref()
            .expect("client was just initialized above")
            .clone()
    }

    pub(crate) async fn get_oss_client(&self) -> OssClient {
        if let Some(client) = self.oss_client.read().await.as_ref() {
            return client.clone();
        }

        let auth = self.get_auth_client().await;
        let mut guard = self.oss_client.write().await;
        if guard.is_none() {
            *guard = Some(OssClient::new_with_http_config(
                (*self.config).clone(),
                auth,
                self.http_config.clone(),
            ));
        }
        guard
            .as_ref()
            .expect("client was just initialized above")
            .clone()
    }

    pub(crate) async fn get_derivative_client(&self) -> DerivativeClient {
        if let Some(client) = self.derivative_client.read().await.as_ref() {
            return client.clone();
        }

        let auth = self.get_auth_client().await;
        let mut guard = self.derivative_client.write().await;
        if guard.is_none() {
            *guard = Some(DerivativeClient::new_with_http_config(
                (*self.config).clone(),
                auth,
                self.http_config.clone(),
            ));
        }
        guard
            .as_ref()
            .expect("client was just initialized above")
            .clone()
    }

    pub(crate) async fn get_dm_client(&self) -> DataManagementClient {
        if let Some(client) = self.dm_client.read().await.as_ref() {
            return client.clone();
        }

        let auth = self.get_auth_client().await;
        let mut guard = self.dm_client.write().await;
        if guard.is_none() {
            *guard = Some(DataManagementClient::new_with_http_config(
                (*self.config).clone(),
                auth,
                self.http_config.clone(),
            ));
        }
        guard
            .as_ref()
            .expect("client was just initialized above")
            .clone()
    }

    // On-demand clients (not cached, created fresh each time)

    pub(crate) async fn get_admin_client(&self) -> AccountAdminClient {
        let auth = self.get_auth_client().await;
        AccountAdminClient::new_with_http_config(
            (*self.config).clone(),
            auth,
            self.http_config.clone(),
        )
    }

    pub(crate) async fn get_users_client(&self) -> ProjectUsersClient {
        let auth = self.get_auth_client().await;
        ProjectUsersClient::new_with_http_config(
            (*self.config).clone(),
            auth,
            self.http_config.clone(),
        )
    }

    pub(crate) async fn get_issues_client(&self) -> IssuesClient {
        let auth = self.get_auth_client().await;
        IssuesClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
    }

    pub(crate) async fn get_rfi_client(&self) -> RfiClient {
        let auth = self.get_auth_client().await;
        RfiClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
    }

    pub(crate) async fn get_acc_client(&self) -> AccClient {
        let auth = self.get_auth_client().await;
        AccClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
    }

    pub(crate) async fn get_permissions_client(&self) -> FolderPermissionsClient {
        let auth = self.get_auth_client().await;
        FolderPermissionsClient::new_with_http_config(
            (*self.config).clone(),
            auth,
            self.http_config.clone(),
        )
    }

    pub(crate) async fn get_webhooks_client(&self) -> WebhooksClient {
        let auth = self.get_auth_client().await;
        WebhooksClient::new_with_http_config((*self.config).clone(), auth, self.http_config.clone())
    }

    pub(crate) async fn get_da_client(&self) -> DesignAutomationClient {
        let auth = self.get_auth_client().await;
        DesignAutomationClient::new_with_http_config(
            (*self.config).clone(),
            auth,
            self.http_config.clone(),
        )
    }

    pub(crate) async fn get_reality_client(&self) -> RealityCaptureClient {
        let auth = self.get_auth_client().await;
        raps_reality::RealityCaptureClient::new_with_http_config(
            (*self.config).clone(),
            auth,
            self.http_config.clone(),
        )
    }

    // ========================================================================
    // Utility helpers
    // ========================================================================

    pub(crate) fn clamp_limit(limit: Option<usize>, default: usize, max: usize) -> usize {
        let limit = limit.unwrap_or(default).max(1);
        limit.min(max)
    }

    pub(crate) fn required_arg(args: &Map<String, Value>, key: &str) -> Result<String, String> {
        args.get(key)
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|v| !v.is_empty())
            .map(|v| v.to_string())
            .ok_or_else(|| format!("Missing required argument '{}'.", key))
    }

    pub(crate) fn optional_arg(args: &Map<String, Value>, key: &str) -> Option<String> {
        args.get(key)
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|v| !v.is_empty())
            .map(|v| v.to_string())
    }

    /// Validate that a URN looks like a base64-encoded APS URN.
    pub(crate) fn validate_urn(urn: &str) -> Result<(), String> {
        if urn.len() < 10 {
            return Err("URN is too short — expected a base64-encoded APS URN.".to_string());
        }
        if urn.contains(' ') {
            return Err("URN must not contain spaces.".to_string());
        }
        Ok(())
    }

    /// Validate that an ID looks like a GUID (with optional prefix like `b.`).
    #[allow(dead_code)]
    pub(crate) fn validate_id(value: &str, label: &str) -> Result<(), String> {
        // Allow prefixed IDs like "b.abc-123" or plain GUIDs
        let id_part = value.rsplit('.').next().unwrap_or(value);
        if id_part.len() < 8 {
            return Err(format!(
                "{} '{}' looks too short — expected a GUID or APS ID.",
                label, value
            ));
        }
        Ok(())
    }
}

// ============================================================================
// Free functions
// ============================================================================

/// Human-readable file-size formatting.
pub(crate) fn format_size(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = KB * 1024;
    const GB: u64 = MB * 1024;

    if bytes >= GB {
        format!("{:.1} GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.1} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.1} KB", bytes as f64 / KB as f64)
    } else {
        format!("{} bytes", bytes)
    }
}

/// Validate that a file path is not pointing at a sensitive system location.
/// Returns Ok(()) if safe, Err(message) if the path should be rejected.
pub(crate) fn validate_file_path(path: &std::path::Path) -> Result<(), String> {
    let path_str = path.to_string_lossy().to_lowercase();

    // Block well-known sensitive paths
    let blocked_patterns = [
        ".ssh",
        ".gnupg",
        ".aws/credentials",
        ".env",
        "id_rsa",
        "id_ed25519",
        "authorized_keys",
        "known_hosts",
        "/etc/shadow",
        "/etc/passwd",
        "/etc/cron",
        "credentials.json",
        "secrets.json",
        "token.json",
    ];

    for pattern in &blocked_patterns {
        if path_str.contains(pattern) {
            return Err(format!(
                "Error: Path '{}' targets a sensitive location (matched '{}').\n\
                 MCP tools cannot read/write security-sensitive files.",
                path.display(),
                pattern
            ));
        }
    }

    Ok(())
}

// ============================================================================
// ServerHandler implementation
// ============================================================================

impl ServerHandler for RapsServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            instructions: Some(format!(
                "RAPS MCP Server v{version} - Autodesk Platform Services CLI\n\n\
                    Provides direct access to APS APIs:\n\
                    * auth_* - Authentication (2-legged and 3-legged OAuth)\n\
                    * bucket_*, object_* - OSS storage operations (incl. upload/download/copy)\n\
                    * translate_* - CAD model translation\n\
                    * hub_*, project_* - Data Management & Project Info\n\
                    * folder_*, item_* - Folder and file management\n\
                    * project_create, project_user_* - ACC Project Admin\n\
                    * template_* - Project template management\n\
                    * admin_* - Bulk account administration\n\
                    * issue_*, rfi_* - ACC Issues and RFIs\n\
                    * acc_* - ACC Assets, Submittals, Checklists\n\
                    * da_* - Design Automation\n\
                    * reality_* - Reality Capture / Photogrammetry\n\
                    * webhook_* - Event subscriptions\n\
                    * api_request - Custom APS API calls\n\
                    * report_* - Portfolio reports\n\n\
                    Set APS_CLIENT_ID and APS_CLIENT_SECRET env vars.\n\
                    For 3-legged auth, run 'raps auth login' first.",
                version = env!("CARGO_PKG_VERSION"),
            )),
            capabilities: ServerCapabilities::builder().enable_tools().build(),
            ..Default::default()
        }
    }

    async fn list_tools(
        &self,
        _request: Option<PaginatedRequestParam>,
        _context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
    ) -> Result<ListToolsResult, rmcp::ErrorData> {
        Ok(ListToolsResult {
            tools: get_tools(),
            next_cursor: None,
            meta: None,
        })
    }

    async fn call_tool(
        &self,
        request: CallToolRequestParam,
        _context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let result = self.dispatch_tool(&request.name, request.arguments).await;
        Ok(result)
    }
}

/// Run the MCP server using stdio transport
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
    // logging::init() in main.rs already set up a global subscriber;
    // no redundant init needed here.

    let server = RapsServer::new()?;
    let service = server.serve(stdio()).await?;
    service.waiting().await?;
    Ok(())
}