1use crate::http::HttpResponse;
28use crate::routing::RouteInfo;
29use std::sync::OnceLock;
30use utoipa::openapi::extensions::ExtensionsBuilder;
31use utoipa::openapi::path::{HttpMethod, OperationBuilder, ParameterBuilder, ParameterIn};
32use utoipa::openapi::schema::{Object, Type};
33use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
34use utoipa::openapi::{
35 ComponentsBuilder, InfoBuilder, OpenApiBuilder, PathItem, PathsBuilder, Required,
36};
37
38#[derive(Debug, Clone)]
40pub struct OpenApiConfig {
41 pub title: String,
43 pub version: String,
45 pub description: Option<String>,
47 pub api_prefix: String,
49}
50
51impl Default for OpenApiConfig {
52 fn default() -> Self {
53 Self {
54 title: "API Documentation".to_string(),
55 version: "1.0.0".to_string(),
56 description: None,
57 api_prefix: "/api/".to_string(),
58 }
59 }
60}
61
62pub fn build_openapi_spec(
67 config: &OpenApiConfig,
68 routes: &[RouteInfo],
69) -> utoipa::openapi::OpenApi {
70 let mut paths = PathsBuilder::new();
71
72 for route in routes
73 .iter()
74 .filter(|r| r.path.starts_with(&config.api_prefix))
75 {
76 let http_method = parse_http_method(&route.method);
77 let tag = extract_tag(&route.path);
78 let summary = auto_summary(&route.method, &route.path);
79
80 let mut op = OperationBuilder::new()
81 .tag(tag)
82 .summary(Some(summary))
83 .operation_id(route.name.clone());
84
85 for param_name in extract_path_params(&route.path) {
87 op = op.parameter(
88 ParameterBuilder::new()
89 .name(param_name)
90 .parameter_in(ParameterIn::Path)
91 .required(Required::True)
92 .schema(Some(Object::with_type(Type::String)))
93 .build(),
94 );
95 }
96
97 let extensions = if route.mcp_hidden {
99 ExtensionsBuilder::new().add("x-mcp-hidden", true).build()
101 } else {
102 let tool_name = route
103 .mcp_tool_name
104 .clone()
105 .unwrap_or_else(|| mcp_tool_name(&route.method, &route.path));
106 let description = route
107 .mcp_description
108 .clone()
109 .unwrap_or_else(|| mcp_description(&route.method, &route.path));
110
111 let mut ext = ExtensionsBuilder::new()
112 .add("x-mcp-tool-name", tool_name)
113 .add("x-mcp-description", description);
114
115 if let Some(hint) = &route.mcp_hint {
116 ext = ext.add("x-mcp-hint", hint.clone());
117 }
118
119 ext.build()
120 };
121 op = op.extensions(Some(extensions));
122
123 let operation = op.build();
124
125 let openapi_path = route.path.clone();
127
128 let path_item = PathItem::new(http_method, operation);
129 paths = paths.path(openapi_path, path_item);
130 }
131
132 let components = ComponentsBuilder::new()
133 .security_scheme(
134 "api_key",
135 SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
136 )
137 .build();
138
139 let info = InfoBuilder::new()
140 .title(&config.title)
141 .version(&config.version)
142 .description(config.description.as_deref())
143 .build();
144
145 OpenApiBuilder::new()
146 .info(info)
147 .paths(paths.build())
148 .components(Some(components))
149 .build()
150}
151
152static CACHED_SPEC_JSON: OnceLock<String> = OnceLock::new();
154static CACHED_DOCS_HTML: OnceLock<String> = OnceLock::new();
155
156pub fn openapi_json_response(config: &OpenApiConfig, routes: &[RouteInfo]) -> HttpResponse {
160 let json = CACHED_SPEC_JSON.get_or_init(|| {
161 let spec = build_openapi_spec(config, routes);
162 spec.to_json()
163 .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize spec: {e}\"}}"))
164 });
165
166 HttpResponse::text(json.clone()).header("Content-Type", "application/json")
167}
168
169pub fn openapi_docs_response(config: &OpenApiConfig, routes: &[RouteInfo]) -> HttpResponse {
173 let html = CACHED_DOCS_HTML.get_or_init(|| {
174 let spec = build_openapi_spec(config, routes);
175 utoipa_redoc::Redoc::new(spec).to_html()
176 });
177
178 HttpResponse::text(html.clone()).header("Content-Type", "text/html; charset=utf-8")
179}
180
181fn parse_http_method(method: &str) -> HttpMethod {
182 match method {
183 "GET" => HttpMethod::Get,
184 "POST" => HttpMethod::Post,
185 "PUT" => HttpMethod::Put,
186 "PATCH" => HttpMethod::Patch,
187 "DELETE" => HttpMethod::Delete,
188 "OPTIONS" => HttpMethod::Options,
189 "HEAD" => HttpMethod::Head,
190 _ => HttpMethod::Get,
191 }
192}
193
194fn extract_tag(path: &str) -> String {
201 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
202
203 let start = segments.iter().position(|s| *s == "api").unwrap_or(0) + 1;
205 let start = if segments
206 .get(start)
207 .is_some_and(|s| s.starts_with('v') && s[1..].chars().all(|c| c.is_ascii_digit()))
208 {
209 start + 1
210 } else {
211 start
212 };
213
214 segments
215 .get(start)
216 .filter(|s| !s.starts_with('{'))
217 .map(|s| s.to_string())
218 .unwrap_or_else(|| "default".to_string())
219}
220
221fn extract_resource_name(path: &str) -> String {
228 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
229
230 let resource = segments
231 .iter()
232 .rev()
233 .find(|s| !s.starts_with('{'))
234 .unwrap_or(&"resource");
235
236 resource.to_string()
237}
238
239fn has_path_param(path: &str) -> bool {
241 path.split('/')
242 .next_back()
243 .is_some_and(|s| s.starts_with('{'))
244}
245
246fn auto_summary(method: &str, path: &str) -> String {
255 let resource = extract_resource_name(path);
256 let singular = singularize(&resource);
257
258 match (method, has_path_param(path)) {
259 ("GET", false) => format!("List {resource}"),
260 ("GET", true) => format!("Get {singular}"),
261 ("POST", _) => format!("Create {singular}"),
262 ("PUT" | "PATCH", _) => format!("Update {singular}"),
263 ("DELETE", _) => format!("Delete {singular}"),
264 _ => format!("{method} {path}"),
265 }
266}
267
268fn singularize(word: &str) -> String {
270 if word.ends_with('s') && word.len() > 1 {
271 word[..word.len() - 1].to_string()
272 } else {
273 word.to_string()
274 }
275}
276
277fn mcp_tool_name(method: &str, path: &str) -> String {
285 let resource = extract_resource_name(path);
286 let singular = singularize(&resource);
287
288 match (method, has_path_param(path)) {
289 ("GET", false) => format!("list_{resource}"),
290 ("GET", true) => format!("get_{singular}"),
291 ("POST", _) => format!("create_{singular}"),
292 ("PUT" | "PATCH", _) => format!("update_{singular}"),
293 ("DELETE", _) => format!("delete_{singular}"),
294 _ => format!("{}_{resource}", method.to_lowercase()),
295 }
296}
297
298fn mcp_description(method: &str, path: &str) -> String {
305 let resource = extract_resource_name(path);
306 let singular = singularize(&resource);
307
308 match (method, has_path_param(path)) {
309 ("GET", false) => format!("List all {resource} with optional pagination."),
310 ("GET", true) => format!("Get a single {singular} by ID."),
311 ("POST", _) => format!("Create a new {singular}."),
312 ("PUT" | "PATCH", _) => format!("Update an existing {singular} by ID."),
313 ("DELETE", _) => format!("Permanently delete a {singular} by ID."),
314 _ => format!("{method} {path}"),
315 }
316}
317
318fn extract_path_params(path: &str) -> Vec<String> {
320 path.split('/')
321 .filter(|s| s.starts_with('{') && s.ends_with('}'))
322 .map(|s| s[1..s.len() - 1].to_string())
323 .collect()
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn auto_summary_list() {
332 assert_eq!(auto_summary("GET", "/api/v1/users"), "List users");
333 }
334
335 #[test]
336 fn auto_summary_get() {
337 assert_eq!(auto_summary("GET", "/api/v1/users/{id}"), "Get user");
338 }
339
340 #[test]
341 fn auto_summary_create() {
342 assert_eq!(auto_summary("POST", "/api/v1/users"), "Create user");
343 }
344
345 #[test]
346 fn auto_summary_update_put() {
347 assert_eq!(auto_summary("PUT", "/api/v1/users/{id}"), "Update user");
348 }
349
350 #[test]
351 fn auto_summary_update_patch() {
352 assert_eq!(auto_summary("PATCH", "/api/v1/users/{id}"), "Update user");
353 }
354
355 #[test]
356 fn auto_summary_delete() {
357 assert_eq!(auto_summary("DELETE", "/api/v1/users/{id}"), "Delete user");
358 }
359
360 #[test]
361 fn auto_summary_fallback() {
362 assert_eq!(
363 auto_summary("OPTIONS", "/api/v1/health"),
364 "OPTIONS /api/v1/health"
365 );
366 }
367
368 #[test]
369 fn extract_resource_name_collection() {
370 assert_eq!(extract_resource_name("/api/v1/users"), "users");
371 }
372
373 #[test]
374 fn extract_resource_name_with_param() {
375 assert_eq!(extract_resource_name("/api/v1/users/{id}"), "users");
376 }
377
378 #[test]
379 fn extract_resource_name_nested() {
380 assert_eq!(
381 extract_resource_name("/api/v1/posts/{id}/comments"),
382 "comments"
383 );
384 }
385
386 #[test]
387 fn extract_tag_simple() {
388 assert_eq!(extract_tag("/api/v1/users"), "users");
389 }
390
391 #[test]
392 fn extract_tag_with_param() {
393 assert_eq!(extract_tag("/api/v1/users/{id}"), "users");
394 }
395
396 #[test]
397 fn extract_tag_no_version() {
398 assert_eq!(extract_tag("/api/users"), "users");
399 }
400
401 #[test]
402 fn extract_tag_nested() {
403 assert_eq!(extract_tag("/api/v1/posts/{id}/comments"), "posts");
404 }
405
406 #[test]
407 fn extract_tag_default() {
408 assert_eq!(extract_tag("/api/v1/{id}"), "default");
409 }
410
411 #[test]
412 fn extract_path_params_none() {
413 assert!(extract_path_params("/api/v1/users").is_empty());
414 }
415
416 #[test]
417 fn extract_path_params_single() {
418 assert_eq!(extract_path_params("/api/v1/users/{id}"), vec!["id"]);
419 }
420
421 #[test]
422 fn extract_path_params_multiple() {
423 assert_eq!(
424 extract_path_params("/api/v1/users/{user_id}/posts/{post_id}"),
425 vec!["user_id", "post_id"]
426 );
427 }
428
429 #[test]
430 fn singularize_plural() {
431 assert_eq!(singularize("users"), "user");
432 }
433
434 #[test]
435 fn singularize_already_singular() {
436 assert_eq!(singularize("user"), "user");
437 }
438
439 #[test]
440 fn singularize_single_char() {
441 assert_eq!(singularize("s"), "s");
442 }
443
444 #[test]
445 fn mcp_tool_name_list() {
446 assert_eq!(mcp_tool_name("GET", "/api/v1/users"), "list_users");
447 }
448
449 #[test]
450 fn mcp_tool_name_get() {
451 assert_eq!(mcp_tool_name("GET", "/api/v1/users/{id}"), "get_user");
452 }
453
454 #[test]
455 fn mcp_tool_name_create() {
456 assert_eq!(mcp_tool_name("POST", "/api/v1/users"), "create_user");
457 }
458
459 #[test]
460 fn mcp_tool_name_update_put() {
461 assert_eq!(mcp_tool_name("PUT", "/api/v1/users/{id}"), "update_user");
462 }
463
464 #[test]
465 fn mcp_tool_name_update_patch() {
466 assert_eq!(mcp_tool_name("PATCH", "/api/v1/users/{id}"), "update_user");
467 }
468
469 #[test]
470 fn mcp_tool_name_delete() {
471 assert_eq!(mcp_tool_name("DELETE", "/api/v1/users/{id}"), "delete_user");
472 }
473
474 #[test]
475 fn mcp_tool_name_nested() {
476 assert_eq!(
477 mcp_tool_name("GET", "/api/v1/posts/{id}/comments"),
478 "list_comments"
479 );
480 }
481
482 #[test]
483 fn mcp_tool_name_fallback() {
484 assert_eq!(mcp_tool_name("OPTIONS", "/api/v1/health"), "options_health");
485 }
486
487 #[test]
488 fn mcp_description_list() {
489 assert_eq!(
490 mcp_description("GET", "/api/v1/users"),
491 "List all users with optional pagination."
492 );
493 }
494
495 #[test]
496 fn mcp_description_get() {
497 assert_eq!(
498 mcp_description("GET", "/api/v1/users/{id}"),
499 "Get a single user by ID."
500 );
501 }
502
503 #[test]
504 fn mcp_description_create() {
505 assert_eq!(
506 mcp_description("POST", "/api/v1/users"),
507 "Create a new user."
508 );
509 }
510
511 #[test]
512 fn mcp_description_update_put() {
513 assert_eq!(
514 mcp_description("PUT", "/api/v1/users/{id}"),
515 "Update an existing user by ID."
516 );
517 }
518
519 #[test]
520 fn mcp_description_update_patch() {
521 assert_eq!(
522 mcp_description("PATCH", "/api/v1/users/{id}"),
523 "Update an existing user by ID."
524 );
525 }
526
527 #[test]
528 fn mcp_description_delete() {
529 assert_eq!(
530 mcp_description("DELETE", "/api/v1/users/{id}"),
531 "Permanently delete a user by ID."
532 );
533 }
534
535 #[test]
536 fn mcp_description_nested() {
537 assert_eq!(
538 mcp_description("GET", "/api/v1/posts/{id}/comments"),
539 "List all comments with optional pagination."
540 );
541 }
542
543 #[test]
544 fn mcp_description_fallback() {
545 assert_eq!(
546 mcp_description("OPTIONS", "/api/v1/health"),
547 "OPTIONS /api/v1/health"
548 );
549 }
550
551 #[test]
552 fn build_spec_basic() {
553 let config = OpenApiConfig {
554 title: "Test API".to_string(),
555 version: "1.0.0".to_string(),
556 description: Some("Test description".to_string()),
557 api_prefix: "/api/".to_string(),
558 };
559
560 let routes = vec![
561 RouteInfo {
562 method: "GET".to_string(),
563 path: "/api/v1/users".to_string(),
564 name: Some("api.users.index".to_string()),
565 middleware: vec![],
566 ..Default::default()
567 },
568 RouteInfo {
569 method: "POST".to_string(),
570 path: "/api/v1/users".to_string(),
571 name: Some("api.users.store".to_string()),
572 middleware: vec![],
573 ..Default::default()
574 },
575 RouteInfo {
576 method: "GET".to_string(),
577 path: "/api/v1/users/{id}".to_string(),
578 name: Some("api.users.show".to_string()),
579 middleware: vec![],
580 ..Default::default()
581 },
582 RouteInfo {
583 method: "PUT".to_string(),
584 path: "/api/v1/users/{id}".to_string(),
585 name: Some("api.users.update".to_string()),
586 middleware: vec![],
587 ..Default::default()
588 },
589 RouteInfo {
590 method: "DELETE".to_string(),
591 path: "/api/v1/users/{id}".to_string(),
592 name: Some("api.users.destroy".to_string()),
593 middleware: vec![],
594 ..Default::default()
595 },
596 ];
597
598 let spec = build_openapi_spec(&config, &routes);
599
600 assert_eq!(spec.info.title, "Test API");
601 assert_eq!(spec.info.version, "1.0.0");
602 assert_eq!(spec.info.description, Some("Test description".to_string()));
603
604 assert_eq!(spec.paths.paths.len(), 2);
606 assert!(spec.paths.paths.contains_key("/api/v1/users"));
607 assert!(spec.paths.paths.contains_key("/api/v1/users/{id}"));
608
609 let users_path = spec.paths.paths.get("/api/v1/users").unwrap();
611 let get_op = users_path.get.as_ref().unwrap();
612 assert_eq!(get_op.summary, Some("List users".to_string()));
613 assert_eq!(get_op.operation_id, Some("api.users.index".to_string()));
614
615 let post_op = users_path.post.as_ref().unwrap();
617 assert_eq!(post_op.summary, Some("Create user".to_string()));
618
619 let user_path = spec.paths.paths.get("/api/v1/users/{id}").unwrap();
621 let get_user_op = user_path.get.as_ref().unwrap();
622 assert_eq!(get_user_op.summary, Some("Get user".to_string()));
623 let params = get_user_op.parameters.as_ref().unwrap();
624 assert_eq!(params.len(), 1);
625 assert_eq!(params[0].name, "id");
626
627 let get_ext = get_op.extensions.as_ref().unwrap();
629 assert_eq!(
630 get_ext.get("x-mcp-tool-name").unwrap(),
631 &serde_json::Value::String("list_users".to_string())
632 );
633 assert_eq!(
634 get_ext.get("x-mcp-description").unwrap(),
635 &serde_json::Value::String("List all users with optional pagination.".to_string())
636 );
637
638 let delete_op = user_path.delete.as_ref().unwrap();
640 let delete_ext = delete_op.extensions.as_ref().unwrap();
641 assert_eq!(
642 delete_ext.get("x-mcp-tool-name").unwrap(),
643 &serde_json::Value::String("delete_user".to_string())
644 );
645 assert_eq!(
646 delete_ext.get("x-mcp-description").unwrap(),
647 &serde_json::Value::String("Permanently delete a user by ID.".to_string())
648 );
649
650 let components = spec.components.as_ref().unwrap();
652 assert!(components.security_schemes.contains_key("api_key"));
653 }
654
655 #[test]
656 fn build_spec_filters_non_api_routes() {
657 let config = OpenApiConfig::default();
658
659 let routes = vec![
660 RouteInfo {
661 method: "GET".to_string(),
662 path: "/api/v1/users".to_string(),
663 name: None,
664 middleware: vec![],
665 ..Default::default()
666 },
667 RouteInfo {
668 method: "GET".to_string(),
669 path: "/dashboard".to_string(),
670 name: None,
671 middleware: vec![],
672 ..Default::default()
673 },
674 RouteInfo {
675 method: "GET".to_string(),
676 path: "/login".to_string(),
677 name: None,
678 middleware: vec![],
679 ..Default::default()
680 },
681 ];
682
683 let spec = build_openapi_spec(&config, &routes);
684
685 assert_eq!(spec.paths.paths.len(), 1);
687 assert!(spec.paths.paths.contains_key("/api/v1/users"));
688 }
689
690 #[test]
691 fn build_spec_empty_routes() {
692 let config = OpenApiConfig::default();
693 let spec = build_openapi_spec(&config, &[]);
694
695 assert!(spec.paths.paths.is_empty());
696 assert_eq!(spec.info.title, "API Documentation");
697 }
698
699 #[test]
700 fn spec_serializes_to_json() {
701 let config = OpenApiConfig::default();
702 let routes = vec![RouteInfo {
703 method: "GET".to_string(),
704 path: "/api/v1/health".to_string(),
705 name: None,
706 middleware: vec![],
707 ..Default::default()
708 }];
709
710 let spec = build_openapi_spec(&config, &routes);
711 let json = spec.to_json().unwrap();
712
713 assert!(json.contains("\"openapi\""));
714 assert!(json.contains("\"info\""));
715 assert!(json.contains("\"paths\""));
716 assert!(json.contains("/api/v1/health"));
717 }
718
719 #[test]
720 fn spec_extensions_in_json() {
721 let config = OpenApiConfig::default();
722 let routes = vec![
723 RouteInfo {
724 method: "GET".to_string(),
725 path: "/api/v1/users".to_string(),
726 name: Some("users.index".to_string()),
727 middleware: vec![],
728 ..Default::default()
729 },
730 RouteInfo {
731 method: "POST".to_string(),
732 path: "/api/v1/users".to_string(),
733 name: Some("users.store".to_string()),
734 middleware: vec![],
735 ..Default::default()
736 },
737 ];
738
739 let spec = build_openapi_spec(&config, &routes);
740 let json_str = spec.to_json().unwrap();
741 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
742
743 let get_op = &parsed["paths"]["/api/v1/users"]["get"];
745 assert_eq!(get_op["x-mcp-tool-name"], "list_users");
746 assert_eq!(
747 get_op["x-mcp-description"],
748 "List all users with optional pagination."
749 );
750
751 let post_op = &parsed["paths"]["/api/v1/users"]["post"];
753 assert_eq!(post_op["x-mcp-tool-name"], "create_user");
754 assert_eq!(post_op["x-mcp-description"], "Create a new user.");
755 }
756
757 #[test]
758 fn spec_explicit_mcp_tool_name_override() {
759 let config = OpenApiConfig::default();
760 let routes = vec![RouteInfo {
761 method: "GET".to_string(),
762 path: "/api/v1/users".to_string(),
763 name: None,
764 middleware: vec![],
765 mcp_tool_name: Some("fetch_all_users".to_string()),
766 ..Default::default()
767 }];
768
769 let spec = build_openapi_spec(&config, &routes);
770 let json_str = spec.to_json().unwrap();
771 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
772
773 let get_op = &parsed["paths"]["/api/v1/users"]["get"];
774 assert_eq!(get_op["x-mcp-tool-name"], "fetch_all_users");
775 assert_eq!(
777 get_op["x-mcp-description"],
778 "List all users with optional pagination."
779 );
780 }
781
782 #[test]
783 fn spec_mcp_hidden_route() {
784 let config = OpenApiConfig::default();
785 let routes = vec![RouteInfo {
786 method: "GET".to_string(),
787 path: "/api/v1/internal/health".to_string(),
788 name: None,
789 middleware: vec![],
790 mcp_hidden: true,
791 ..Default::default()
792 }];
793
794 let spec = build_openapi_spec(&config, &routes);
795 let json_str = spec.to_json().unwrap();
796 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
797
798 let get_op = &parsed["paths"]["/api/v1/internal/health"]["get"];
799 assert_eq!(get_op["x-mcp-hidden"], true);
800 assert!(get_op.get("x-mcp-tool-name").is_none());
802 assert!(get_op.get("x-mcp-description").is_none());
803 }
804
805 #[test]
806 fn spec_mcp_hint_extension() {
807 let config = OpenApiConfig::default();
808 let routes = vec![RouteInfo {
809 method: "POST".to_string(),
810 path: "/api/v1/users".to_string(),
811 name: None,
812 middleware: vec![],
813 mcp_hint: Some("Requires admin role. Email must be unique.".to_string()),
814 ..Default::default()
815 }];
816
817 let spec = build_openapi_spec(&config, &routes);
818 let json_str = spec.to_json().unwrap();
819 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
820
821 let post_op = &parsed["paths"]["/api/v1/users"]["post"];
822 assert_eq!(
823 post_op["x-mcp-hint"],
824 "Requires admin role. Email must be unique."
825 );
826 assert_eq!(post_op["x-mcp-tool-name"], "create_user");
828 assert_eq!(post_op["x-mcp-description"], "Create a new user.");
829 }
830
831 #[test]
832 fn spec_no_mcp_overrides_uses_auto_generated() {
833 let config = OpenApiConfig::default();
834 let routes = vec![RouteInfo {
835 method: "DELETE".to_string(),
836 path: "/api/v1/users/{id}".to_string(),
837 name: None,
838 middleware: vec![],
839 ..Default::default()
840 }];
841
842 let spec = build_openapi_spec(&config, &routes);
843 let json_str = spec.to_json().unwrap();
844 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
845
846 let delete_op = &parsed["paths"]["/api/v1/users/{id}"]["delete"];
847 assert_eq!(delete_op["x-mcp-tool-name"], "delete_user");
848 assert_eq!(
849 delete_op["x-mcp-description"],
850 "Permanently delete a user by ID."
851 );
852 assert!(delete_op.get("x-mcp-hint").is_none());
854 assert!(delete_op.get("x-mcp-hidden").is_none());
855 }
856
857 #[test]
858 fn spec_mcp_description_override() {
859 let config = OpenApiConfig::default();
860 let routes = vec![RouteInfo {
861 method: "GET".to_string(),
862 path: "/api/v1/users".to_string(),
863 name: None,
864 middleware: vec![],
865 mcp_description: Some("Retrieve all active users, sorted by name.".to_string()),
866 ..Default::default()
867 }];
868
869 let spec = build_openapi_spec(&config, &routes);
870 let json_str = spec.to_json().unwrap();
871 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
872
873 let get_op = &parsed["paths"]["/api/v1/users"]["get"];
874 assert_eq!(
875 get_op["x-mcp-description"],
876 "Retrieve all active users, sorted by name."
877 );
878 assert_eq!(get_op["x-mcp-tool-name"], "list_users");
880 }
881}