Skip to main content

onshape_mcp_core/
lib.rs

1//! Pure MCP protocol logic for Onshape integration.
2//!
3//! This crate contains sans-IO business logic with no async runtime dependencies.
4//! All I/O operations are handled by the `onshape-mcp-io` crate.
5
6pub mod config;
7pub mod openapi;
8pub mod tools;
9
10use chrono::{DateTime, Utc};
11use onshape_client_core::auth::AuthMethod;
12use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::config::ResolvedAuth;
17
18/// Authentication status for the Onshape API connection.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum AuthStatus {
22    /// Credentials are valid and working.
23    Valid,
24    /// Credentials are invalid (wrong key/secret).
25    Invalid,
26    /// Credentials have expired.
27    Expired,
28    /// No credentials have been configured.
29    NotConfigured,
30    /// Credentials are configured but have not been validated against the API.
31    NotValidated,
32}
33
34/// Whether credentials have been confirmed working via an API call.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum ValidationStatus {
38    /// Credentials confirmed working via API call.
39    Valid,
40    /// Credentials confirmed invalid (API returned 401).
41    Invalid,
42    /// Credentials not yet validated against the API.
43    NotValidated,
44}
45
46/// Runtime credential validation state.
47///
48/// Tracks whether credentials have been confirmed working against the
49/// Onshape API. This is a runtime concern managed by the I/O layer,
50/// separate from [`ResolvedAuth`] which represents credential configuration.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
52pub struct ValidationState {
53    /// Whether credentials have been validated.
54    pub status: ValidationStatus,
55    /// When the last validation check occurred.
56    pub last_check: Option<DateTime<Utc>>,
57    /// Human-readable detail about the validation result.
58    pub message: Option<String>,
59}
60
61impl Default for ValidationState {
62    fn default() -> Self {
63        Self {
64            status: ValidationStatus::NotValidated,
65            last_check: None,
66            message: None,
67        }
68    }
69}
70
71/// Result of checking authentication status.
72#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73pub struct AuthStatusResult {
74    /// Current authentication status.
75    pub status: AuthStatus,
76    /// Configured authentication method.
77    pub auth_method: AuthMethod,
78    /// Timestamp of the last authentication check, if any.
79    pub last_check: Option<DateTime<Utc>>,
80    /// Human-readable message explaining the status.
81    pub message: Option<String>,
82}
83
84impl AuthStatusResult {
85    /// Build an auth status result from resolved auth state and optional
86    /// runtime validation state.
87    ///
88    /// This is the primary constructor — it maps the core's [`ResolvedAuth`]
89    /// to a user-facing status result, then overlays any runtime validation
90    /// information. The `now` parameter is needed to determine whether OAuth
91    /// tokens have expired.
92    ///
93    /// Validation can override the status for states where credentials are
94    /// present (`Basic`, `OAuthReady`).  `Expired` can also be overridden
95    /// because the I/O layer proactively refreshes tokens before executing
96    /// the validation request.  `NotConfigured` and `OAuthPending` take
97    /// precedence over validation results because they represent structural
98    /// issues where no credentials exist to validate.
99    #[must_use]
100    pub fn new(
101        resolved: &ResolvedAuth,
102        validation: Option<&ValidationState>,
103        now: DateTime<Utc>,
104    ) -> Self {
105        let mut result = match resolved {
106            ResolvedAuth::NotConfigured {
107                configured_method,
108                detail,
109            } => Self {
110                status: AuthStatus::NotConfigured,
111                auth_method: *configured_method,
112                last_check: None,
113                message: Some(detail.clone()),
114            },
115            ResolvedAuth::Basic => Self {
116                status: AuthStatus::NotValidated,
117                auth_method: AuthMethod::Basic,
118                last_check: None,
119                message: Some(
120                    "Credentials configured but not yet validated against Onshape API".into(),
121                ),
122            },
123            ResolvedAuth::OAuthReady { expires_at } => {
124                if expires_at.is_some_and(|ea| ea <= now) {
125                    Self {
126                        status: AuthStatus::Expired,
127                        auth_method: AuthMethod::OAuth,
128                        last_check: None,
129                        message: Some(
130                            "OAuth access token has expired. \
131                             Token refresh will be attempted on next API call."
132                                .into(),
133                        ),
134                    }
135                } else {
136                    Self {
137                        status: AuthStatus::NotValidated,
138                        auth_method: AuthMethod::OAuth,
139                        last_check: None,
140                        message: Some(
141                            "OAuth access token present but not yet validated against Onshape API"
142                                .into(),
143                        ),
144                    }
145                }
146            }
147            ResolvedAuth::OAuthPending => Self {
148                status: AuthStatus::NotConfigured,
149                auth_method: AuthMethod::OAuth,
150                last_check: None,
151                message: Some(
152                    "OAuth client credentials configured but no access token present. \
153                     Complete the OAuth authorization flow to obtain tokens."
154                        .into(),
155                ),
156            },
157        };
158
159        // Overlay validation state for states where credentials are present.
160        // NotConfigured and OAuthPending take precedence — those represent
161        // structural issues that validation cannot override.
162        //
163        // Expired CAN be overridden by Valid: the I/O layer proactively
164        // refreshes expired tokens before executing the validation request
165        // (GET /users/sessioninfo).  If that request succeeds (200), the
166        // token was refreshed and the session is valid — reporting Expired
167        // would be misleading.
168        if let Some(v) = validation {
169            let can_override = matches!(
170                result.status,
171                AuthStatus::NotValidated | AuthStatus::Expired
172            );
173            if can_override {
174                match v.status {
175                    ValidationStatus::Valid => {
176                        result.status = AuthStatus::Valid;
177                        result.last_check = v.last_check;
178                        result.message = Some(
179                            v.message
180                                .clone()
181                                .unwrap_or_else(|| "Credentials validated successfully".into()),
182                        );
183                    }
184                    ValidationStatus::Invalid => {
185                        result.status = AuthStatus::Invalid;
186                        result.last_check = v.last_check;
187                        result.message = Some(
188                            v.message
189                                .clone()
190                                .unwrap_or_else(|| "Credentials are invalid".into()),
191                        );
192                    }
193                    ValidationStatus::NotValidated => {
194                        // No change — keep the existing NotValidated status.
195                    }
196                }
197            }
198        }
199
200        result
201    }
202
203    /// Build an auth status result from the resolved auth state alone.
204    ///
205    /// Convenience wrapper around [`Self::new`] that does not include
206    /// runtime validation state. Useful when only the credential topology
207    /// is known (e.g. during initial startup before any API calls).
208    #[must_use]
209    pub fn from_resolved(resolved: &ResolvedAuth, now: DateTime<Utc>) -> Self {
210        Self::new(resolved, None, now)
211    }
212}
213
214/// The iconic Onshape regeneration success message.
215pub const CATCH_PHRASE: &str =
216    "Model regeneration complete. No rebuild errors. All features resolved.";
217
218/// The instructions text shared between the initialize response and the
219/// `onshape_mcp_get_started` tool.
220#[must_use]
221pub fn instructions() -> String {
222    format!(
223        "Onshape MCP server for CAD integration. \
224         This server provides insight resources with practical Onshape API \
225         guidance. Before calling endpoints for the first time, list and \
226         read relevant resources to avoid common pitfalls. \
227         {CATCH_PHRASE}"
228    )
229}
230
231/// Creates the server info for MCP initialization.
232///
233/// # Arguments
234///
235/// * `name` - The server name (typically from `CARGO_PKG_NAME`)
236/// * `version` - The server version (typically from `CARGO_PKG_VERSION`)
237#[must_use]
238pub fn server_info(name: &str, version: &str) -> ServerInfo {
239    ServerInfo::new(
240        ServerCapabilities::builder()
241            .enable_tools()
242            .enable_resources()
243            .build(),
244    )
245    .with_server_info(Implementation::new(name, version))
246    .with_instructions(instructions())
247}
248
249#[cfg(test)]
250#[allow(clippy::expect_used)]
251mod tests {
252    use chrono::TimeZone;
253
254    use super::*;
255
256    fn now() -> DateTime<Utc> {
257        Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0)
258            .single()
259            .expect("valid datetime")
260    }
261
262    #[test]
263    fn server_info_sets_name_and_version() {
264        let info = server_info("test-server", "1.2.3");
265
266        assert_eq!(info.server_info.name, "test-server");
267        assert_eq!(info.server_info.version, "1.2.3");
268    }
269
270    #[test]
271    fn server_info_enables_tools_capability() {
272        let info = server_info("test", "0.0.0");
273
274        assert!(info.capabilities.tools.is_some());
275    }
276
277    #[test]
278    fn server_info_enables_resources_capability() {
279        let info = server_info("test", "0.0.0");
280
281        assert!(info.capabilities.resources.is_some());
282    }
283
284    #[test]
285    fn server_info_includes_instructions() {
286        let info = server_info("test", "0.0.0");
287
288        let instructions = info.instructions.expect("instructions should be set");
289        assert!(instructions.contains("Onshape MCP server"));
290        assert!(instructions.contains("insight resources"));
291        assert!(instructions.contains(CATCH_PHRASE));
292    }
293
294    // ====================================================================
295    // AuthStatusResult::from_resolved Tests
296    // ====================================================================
297
298    #[test]
299    fn from_resolved_not_configured_basic() {
300        let resolved = ResolvedAuth::NotConfigured {
301            configured_method: AuthMethod::Basic,
302            detail: "No credentials configured".into(),
303        };
304        let result = AuthStatusResult::from_resolved(&resolved, now());
305
306        assert_eq!(result.status, AuthStatus::NotConfigured);
307        assert_eq!(result.auth_method, AuthMethod::Basic);
308        assert!(result.last_check.is_none());
309        assert_eq!(result.message.as_deref(), Some("No credentials configured"));
310    }
311
312    #[test]
313    fn from_resolved_not_configured_auto() {
314        let resolved = ResolvedAuth::NotConfigured {
315            configured_method: AuthMethod::Auto,
316            detail: "No complete credentials found. Missing: API keys".into(),
317        };
318        let result = AuthStatusResult::from_resolved(&resolved, now());
319
320        assert_eq!(result.status, AuthStatus::NotConfigured);
321        assert_eq!(result.auth_method, AuthMethod::Auto);
322    }
323
324    #[test]
325    fn from_resolved_not_configured_serializes() {
326        let resolved = ResolvedAuth::NotConfigured {
327            configured_method: AuthMethod::Basic,
328            detail: "No credentials configured".into(),
329        };
330        let result = AuthStatusResult::from_resolved(&resolved, now());
331        let json = serde_json::to_string(&result).expect("should serialize");
332
333        assert!(json.contains("\"status\":\"not_configured\""));
334        assert!(json.contains("\"auth_method\":\"basic\""));
335    }
336
337    #[test]
338    fn from_resolved_basic() {
339        let result = AuthStatusResult::from_resolved(&ResolvedAuth::Basic, now());
340
341        assert_eq!(result.status, AuthStatus::NotValidated);
342        assert_eq!(result.auth_method, AuthMethod::Basic);
343        assert!(
344            result
345                .message
346                .as_deref()
347                .is_some_and(|m| m.contains("not yet validated"))
348        );
349    }
350
351    #[test]
352    fn from_resolved_basic_serializes() {
353        let result = AuthStatusResult::from_resolved(&ResolvedAuth::Basic, now());
354        let json = serde_json::to_string(&result).expect("should serialize");
355
356        assert!(json.contains("\"status\":\"not_validated\""));
357    }
358
359    #[test]
360    fn from_resolved_oauth_ready_not_expired() {
361        let future = now() + chrono::Duration::hours(1);
362        let resolved = ResolvedAuth::OAuthReady {
363            expires_at: Some(future),
364        };
365        let result = AuthStatusResult::from_resolved(&resolved, now());
366
367        assert_eq!(result.status, AuthStatus::NotValidated);
368        assert_eq!(result.auth_method, AuthMethod::OAuth);
369        assert!(
370            result
371                .message
372                .as_deref()
373                .is_some_and(|m| m.contains("not yet validated"))
374        );
375    }
376
377    #[test]
378    fn from_resolved_oauth_ready_expired() {
379        let past = now() - chrono::Duration::hours(1);
380        let resolved = ResolvedAuth::OAuthReady {
381            expires_at: Some(past),
382        };
383        let result = AuthStatusResult::from_resolved(&resolved, now());
384
385        assert_eq!(result.status, AuthStatus::Expired);
386        assert_eq!(result.auth_method, AuthMethod::OAuth);
387        assert!(
388            result
389                .message
390                .as_deref()
391                .is_some_and(|m| m.contains("expired"))
392        );
393    }
394
395    #[test]
396    fn from_resolved_oauth_ready_no_expiry() {
397        let resolved = ResolvedAuth::OAuthReady { expires_at: None };
398        let result = AuthStatusResult::from_resolved(&resolved, now());
399
400        assert_eq!(result.status, AuthStatus::NotValidated);
401        assert_eq!(result.auth_method, AuthMethod::OAuth);
402    }
403
404    #[test]
405    fn from_resolved_oauth_pending() {
406        let result = AuthStatusResult::from_resolved(&ResolvedAuth::OAuthPending, now());
407
408        assert_eq!(result.status, AuthStatus::NotConfigured);
409        assert_eq!(result.auth_method, AuthMethod::OAuth);
410        assert!(
411            result
412                .message
413                .as_deref()
414                .is_some_and(|m| m.contains("no access token"))
415        );
416    }
417
418    #[test]
419    fn from_resolved_oauth_pending_serializes() {
420        let result = AuthStatusResult::from_resolved(&ResolvedAuth::OAuthPending, now());
421        let json = serde_json::to_string(&result).expect("should serialize");
422
423        assert!(json.contains("\"auth_method\":\"oauth\""));
424        assert!(json.contains("\"status\":\"not_configured\""));
425    }
426
427    // ====================================================================
428    // ValidationState Tests
429    // ====================================================================
430
431    #[test]
432    fn validation_state_default_is_not_validated() {
433        let state = ValidationState::default();
434        assert_eq!(state.status, ValidationStatus::NotValidated);
435        assert!(state.last_check.is_none());
436        assert!(state.message.is_none());
437    }
438
439    #[test]
440    fn validation_state_serializes() {
441        let state = ValidationState {
442            status: ValidationStatus::Valid,
443            last_check: Some(now()),
444            message: Some("ok".into()),
445        };
446        let json = serde_json::to_string(&state).expect("should serialize");
447        assert!(json.contains("\"status\":\"valid\""));
448        assert!(json.contains("\"message\":\"ok\""));
449    }
450
451    #[test]
452    fn validation_state_deserializes() {
453        let json = r#"{"status":"invalid","last_check":null,"message":"bad"}"#;
454        let state: ValidationState = serde_json::from_str(json).expect("should deserialize");
455        assert_eq!(state.status, ValidationStatus::Invalid);
456        assert!(state.last_check.is_none());
457        assert_eq!(state.message.as_deref(), Some("bad"));
458    }
459
460    // ====================================================================
461    // AuthStatusResult::new() Tests
462    // ====================================================================
463
464    fn valid_validation() -> ValidationState {
465        ValidationState {
466            status: ValidationStatus::Valid,
467            last_check: Some(now()),
468            message: Some("Credentials validated successfully".into()),
469        }
470    }
471
472    fn invalid_validation() -> ValidationState {
473        ValidationState {
474            status: ValidationStatus::Invalid,
475            last_check: Some(now()),
476            message: Some("API returned 401 Unauthorized".into()),
477        }
478    }
479
480    fn not_validated_validation() -> ValidationState {
481        ValidationState::default()
482    }
483
484    #[test]
485    fn new_basic_with_valid_validation_overrides_to_valid() {
486        let result = AuthStatusResult::new(&ResolvedAuth::Basic, Some(&valid_validation()), now());
487        assert_eq!(result.status, AuthStatus::Valid);
488        assert!(result.last_check.is_some());
489        assert!(
490            result
491                .message
492                .as_deref()
493                .is_some_and(|m| m.contains("validated"))
494        );
495    }
496
497    #[test]
498    fn new_basic_with_invalid_validation_overrides_to_invalid() {
499        let result =
500            AuthStatusResult::new(&ResolvedAuth::Basic, Some(&invalid_validation()), now());
501        assert_eq!(result.status, AuthStatus::Invalid);
502        assert!(result.last_check.is_some());
503        assert!(result.message.as_deref().is_some_and(|m| m.contains("401")));
504    }
505
506    #[test]
507    fn new_basic_with_not_validated_keeps_not_validated() {
508        let result = AuthStatusResult::new(
509            &ResolvedAuth::Basic,
510            Some(&not_validated_validation()),
511            now(),
512        );
513        assert_eq!(result.status, AuthStatus::NotValidated);
514    }
515
516    #[test]
517    fn new_basic_with_none_validation_is_not_validated() {
518        let result = AuthStatusResult::new(&ResolvedAuth::Basic, None, now());
519        assert_eq!(result.status, AuthStatus::NotValidated);
520    }
521
522    #[test]
523    fn new_oauth_ready_with_valid_validation_overrides() {
524        let resolved = ResolvedAuth::OAuthReady {
525            expires_at: Some(now() + chrono::Duration::hours(1)),
526        };
527        let result = AuthStatusResult::new(&resolved, Some(&valid_validation()), now());
528        assert_eq!(result.status, AuthStatus::Valid);
529        assert_eq!(result.auth_method, AuthMethod::OAuth);
530    }
531
532    #[test]
533    fn new_oauth_ready_with_invalid_validation_overrides() {
534        let resolved = ResolvedAuth::OAuthReady {
535            expires_at: Some(now() + chrono::Duration::hours(1)),
536        };
537        let result = AuthStatusResult::new(&resolved, Some(&invalid_validation()), now());
538        assert_eq!(result.status, AuthStatus::Invalid);
539        assert_eq!(result.auth_method, AuthMethod::OAuth);
540    }
541
542    #[test]
543    fn new_oauth_ready_expired_overridden_by_valid() {
544        let past = now() - chrono::Duration::hours(1);
545        let resolved = ResolvedAuth::OAuthReady {
546            expires_at: Some(past),
547        };
548        let result = AuthStatusResult::new(&resolved, Some(&valid_validation()), now());
549        // The I/O layer proactively refreshes expired tokens before the
550        // validation request.  If validation succeeded (Valid), the token
551        // was refreshed — report Valid, not Expired.
552        assert_eq!(result.status, AuthStatus::Valid);
553    }
554
555    #[test]
556    fn new_oauth_ready_expired_not_overridden_by_invalid() {
557        let past = now() - chrono::Duration::hours(1);
558        let resolved = ResolvedAuth::OAuthReady {
559            expires_at: Some(past),
560        };
561        let result = AuthStatusResult::new(&resolved, Some(&invalid_validation()), now());
562        // If validation returned Invalid, the refresh failed — Expired
563        // is still the most accurate status.
564        assert_eq!(result.status, AuthStatus::Invalid);
565    }
566
567    #[test]
568    fn new_oauth_ready_expired_not_overridden_by_not_validated() {
569        let past = now() - chrono::Duration::hours(1);
570        let resolved = ResolvedAuth::OAuthReady {
571            expires_at: Some(past),
572        };
573        let result = AuthStatusResult::new(&resolved, Some(&not_validated_validation()), now());
574        // No validation result — Expired status preserved.
575        assert_eq!(result.status, AuthStatus::Expired);
576    }
577
578    #[test]
579    fn new_not_configured_not_overridden_by_valid() {
580        let resolved = ResolvedAuth::NotConfigured {
581            configured_method: AuthMethod::Basic,
582            detail: "No creds".into(),
583        };
584        let result = AuthStatusResult::new(&resolved, Some(&valid_validation()), now());
585        assert_eq!(result.status, AuthStatus::NotConfigured);
586    }
587
588    #[test]
589    fn new_oauth_pending_not_overridden_by_valid() {
590        let result = AuthStatusResult::new(
591            &ResolvedAuth::OAuthPending,
592            Some(&valid_validation()),
593            now(),
594        );
595        assert_eq!(result.status, AuthStatus::NotConfigured);
596    }
597
598    #[test]
599    fn new_not_configured_not_overridden_by_invalid() {
600        let resolved = ResolvedAuth::NotConfigured {
601            configured_method: AuthMethod::Auto,
602            detail: "Nothing".into(),
603        };
604        let result = AuthStatusResult::new(&resolved, Some(&invalid_validation()), now());
605        assert_eq!(result.status, AuthStatus::NotConfigured);
606    }
607
608    #[test]
609    fn new_matches_from_resolved_when_no_validation() {
610        // Verify backward compatibility: new(..., None, ...) == from_resolved(...)
611        let resolved_states = vec![
612            ResolvedAuth::Basic,
613            ResolvedAuth::OAuthReady { expires_at: None },
614            ResolvedAuth::OAuthPending,
615            ResolvedAuth::NotConfigured {
616                configured_method: AuthMethod::Auto,
617                detail: "test".into(),
618            },
619        ];
620        for resolved in &resolved_states {
621            let from_new = AuthStatusResult::new(resolved, None, now());
622            let from_old = AuthStatusResult::from_resolved(resolved, now());
623            assert_eq!(from_new, from_old, "mismatch for {resolved:?}");
624        }
625    }
626}