1pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum AuthStatus {
22 Valid,
24 Invalid,
26 Expired,
28 NotConfigured,
30 NotValidated,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "snake_case")]
37pub enum ValidationStatus {
38 Valid,
40 Invalid,
42 NotValidated,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
52pub struct ValidationState {
53 pub status: ValidationStatus,
55 pub last_check: Option<DateTime<Utc>>,
57 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73pub struct AuthStatusResult {
74 pub status: AuthStatus,
76 pub auth_method: AuthMethod,
78 pub last_check: Option<DateTime<Utc>>,
80 pub message: Option<String>,
82}
83
84impl AuthStatusResult {
85 #[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 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 }
196 }
197 }
198 }
199
200 result
201 }
202
203 #[must_use]
209 pub fn from_resolved(resolved: &ResolvedAuth, now: DateTime<Utc>) -> Self {
210 Self::new(resolved, None, now)
211 }
212}
213
214pub const CATCH_PHRASE: &str =
216 "Model regeneration complete. No rebuild errors. All features resolved.";
217
218#[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#[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 #[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 #[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 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(¬_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 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 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(¬_validated_validation()), now());
574 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 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}