1use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody};
2use serde_json::Value;
3
4use crate::error::Error;
5use crate::types::{ApiOperation, ApiParam, ParamLocation};
6
7pub struct SpecMetadata {
9 pub title: String,
11 pub server_url: Option<String>,
13}
14
15pub fn extract_metadata(json: &str) -> Result<SpecMetadata, Error> {
20 let spec: OpenAPI = serde_json::from_str(json).map_err(|e| Error::SpecParse(e.to_string()))?;
21
22 let title = if spec.info.title.is_empty() {
23 "API".to_string()
24 } else {
25 spec.info.title.clone()
26 };
27
28 let server_url = spec.servers.first().map(|s| s.url.clone());
29
30 Ok(SpecMetadata { title, server_url })
31}
32
33pub async fn fetch_spec(url: &str) -> Result<String, Error> {
39 let response = reqwest::get(url)
40 .await
41 .map_err(|e| categorize_reqwest_error(url, &e))?;
42
43 let status = response.status();
44 if !status.is_success() {
45 return Err(Error::SpecFetch(format!(
46 "HTTP {status} — expected 200 with OpenAPI JSON"
47 )));
48 }
49
50 let body = response
51 .text()
52 .await
53 .map_err(|e| categorize_reqwest_error(url, &e))?;
54
55 if serde_json::from_str::<Value>(&body).is_err() {
57 return Err(Error::SpecFetch(
58 "response is not valid JSON — expected an OpenAPI 3.0.x JSON document".into(),
59 ));
60 }
61
62 Ok(body)
63}
64
65fn categorize_reqwest_error(url: &str, e: &reqwest::Error) -> Error {
67 if e.is_connect() {
68 Error::SpecFetch(format!(
69 "connection refused — is the server running at {url}?"
70 ))
71 } else if e.is_timeout() {
72 Error::SpecFetch("request timed out — check network connectivity".into())
73 } else if e.is_decode() {
74 Error::SpecFetch("failed to decode response body".into())
75 } else {
76 Error::SpecFetch(e.to_string())
77 }
78}
79
80pub fn parse_spec(json: &str) -> Result<Vec<ApiOperation>, Error> {
86 let spec: OpenAPI = serde_json::from_str(json).map_err(|e| Error::SpecParse(e.to_string()))?;
87
88 if !spec.openapi.starts_with("3.") {
89 return Err(Error::UnsupportedVersion(spec.openapi.clone()));
90 }
91
92 let mut operations = Vec::new();
93
94 for (path, path_item_ref) in &spec.paths.paths {
95 let path_item = match path_item_ref {
96 ReferenceOr::Item(item) => item,
97 ReferenceOr::Reference { .. } => continue,
98 };
99
100 let methods: &[(&str, &Option<Operation>)] = &[
101 ("GET", &path_item.get),
102 ("POST", &path_item.post),
103 ("PUT", &path_item.put),
104 ("PATCH", &path_item.patch),
105 ("DELETE", &path_item.delete),
106 ];
107
108 for &(method, op_opt) in methods {
109 if let Some(operation) = op_opt {
110 let hidden = operation
112 .extensions
113 .get("x-mcp-hidden")
114 .and_then(|v| v.as_bool())
115 .unwrap_or(false);
116 if hidden {
117 continue;
118 }
119
120 let mcp_tool_name = operation
122 .extensions
123 .get("x-mcp-tool-name")
124 .and_then(|v| v.as_str())
125 .map(String::from);
126 let mcp_description = operation
127 .extensions
128 .get("x-mcp-description")
129 .and_then(|v| v.as_str())
130 .map(String::from);
131 let mcp_hint = operation
132 .extensions
133 .get("x-mcp-hint")
134 .and_then(|v| v.as_str())
135 .map(String::from);
136
137 let tool_name = mcp_tool_name.unwrap_or_else(|| {
139 generate_tool_name(operation.operation_id.as_deref(), method, path)
140 });
141 let description =
142 mcp_description.unwrap_or_else(|| build_description(operation, &tool_name));
143
144 let parameters =
145 extract_parameters(&spec, &operation.parameters, &path_item.parameters);
146 let request_body_schema = extract_request_body(&spec, &operation.request_body);
147
148 let mut op =
149 ApiOperation::new(tool_name, method.to_string(), path.clone(), description);
150 op.parameters = parameters;
151 op.request_body_schema = request_body_schema;
152 op.hint = mcp_hint;
153
154 operations.push(op);
155 }
156 }
157 }
158
159 Ok(operations)
160}
161
162fn generate_tool_name(operation_id: Option<&str>, method: &str, path: &str) -> String {
167 if let Some(id) = operation_id {
168 return id.replace('.', "_");
169 }
170
171 let sanitized_path = path
172 .split('/')
173 .filter(|s| !s.is_empty() && !s.starts_with('{'))
174 .collect::<Vec<_>>()
175 .join("_");
176
177 format!("{}_{}", method.to_lowercase(), sanitized_path)
178}
179
180fn build_description(operation: &Operation, tool_name: &str) -> String {
184 match (&operation.summary, &operation.description) {
185 (Some(summary), Some(desc)) => format!("{summary} - {desc}"),
186 (Some(summary), None) => summary.clone(),
187 (None, Some(desc)) => desc.clone(),
188 (None, None) => tool_name.to_string(),
189 }
190}
191
192fn extract_parameters(
198 spec: &OpenAPI,
199 operation_params: &[ReferenceOr<Parameter>],
200 path_params: &[ReferenceOr<Parameter>],
201) -> Vec<ApiParam> {
202 let mut result = Vec::new();
203 let mut seen_names = std::collections::HashSet::new();
204
205 for param_ref in operation_params {
207 if let Some(param) = resolve_parameter(spec, param_ref) {
208 seen_names.insert(param.name.clone());
209 result.push(param);
210 }
211 }
212
213 for param_ref in path_params {
215 if let Some(param) = resolve_parameter(spec, param_ref) {
216 if seen_names.insert(param.name.clone()) {
217 result.push(param);
218 }
219 }
220 }
221
222 result
223}
224
225fn resolve_parameter(spec: &OpenAPI, param_ref: &ReferenceOr<Parameter>) -> Option<ApiParam> {
227 let param = match param_ref {
228 ReferenceOr::Item(p) => p,
229 ReferenceOr::Reference { reference } => resolve_parameter_ref(spec, reference)?,
230 };
231
232 let data = param.parameter_data_ref();
233 let location = match param {
234 Parameter::Path { .. } => ParamLocation::Path,
235 Parameter::Query { .. } => ParamLocation::Query,
236 Parameter::Header { .. } => ParamLocation::Header,
237 Parameter::Cookie { .. } => return None, };
239
240 let schema = extract_parameter_schema(data);
241
242 Some(ApiParam {
243 name: data.name.clone(),
244 location,
245 required: data.required,
246 schema,
247 description: data.description.clone(),
248 })
249}
250
251fn resolve_parameter_ref<'a>(spec: &'a OpenAPI, reference: &str) -> Option<&'a Parameter> {
253 let name = reference.strip_prefix("#/components/parameters/")?;
254 let components = spec.components.as_ref()?;
255 let param_ref = components.parameters.get(name)?;
256
257 match param_ref {
258 ReferenceOr::Item(p) => Some(p),
259 ReferenceOr::Reference { .. } => {
260 tracing::warn!("nested $ref in parameter {reference}, skipping");
261 None
262 }
263 }
264}
265
266fn extract_parameter_schema(data: &openapiv3::ParameterData) -> Value {
268 match &data.format {
269 openapiv3::ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
270 ReferenceOr::Item(schema) => {
271 serde_json::to_value(schema).unwrap_or(Value::Object(Default::default()))
272 }
273 ReferenceOr::Reference { .. } => {
274 Value::Object(Default::default())
276 }
277 },
278 openapiv3::ParameterSchemaOrContent::Content(_) => Value::Object(Default::default()),
279 }
280}
281
282fn extract_request_body(spec: &OpenAPI, body: &Option<ReferenceOr<RequestBody>>) -> Option<Value> {
288 let body_ref = body.as_ref()?;
289
290 let request_body = match body_ref {
291 ReferenceOr::Item(rb) => rb,
292 ReferenceOr::Reference { reference } => {
293 tracing::warn!("$ref for requestBody not yet supported: {reference}");
294 return None;
295 }
296 };
297
298 let media_type = request_body.content.get("application/json")?;
299 let schema_ref = media_type.schema.as_ref()?;
300
301 match schema_ref {
302 ReferenceOr::Item(schema) => serde_json::to_value(schema).ok(),
303 ReferenceOr::Reference { reference } => resolve_schema_ref(spec, reference),
304 }
305}
306
307fn resolve_schema_ref(spec: &OpenAPI, reference: &str) -> Option<Value> {
312 let name = reference
313 .strip_prefix("#/components/schemas/")
314 .or_else(|| {
315 tracing::warn!("unsupported $ref path: {reference}");
316 None
317 })?;
318
319 let components = spec.components.as_ref().or_else(|| {
320 tracing::warn!("$ref {reference} but no components section");
321 None
322 })?;
323
324 let schema_ref = components.schemas.get(name).or_else(|| {
325 tracing::warn!("unresolved $ref: {reference}");
326 None
327 })?;
328
329 match schema_ref {
330 ReferenceOr::Item(schema) => serde_json::to_value(schema).ok(),
331 ReferenceOr::Reference { reference: nested } => {
332 tracing::warn!("nested $ref in schema {reference} -> {nested}, skipping");
333 None
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use serde_json::json;
342
343 fn spec_shell(paths: serde_json::Value) -> String {
345 json!({
346 "openapi": "3.0.3",
347 "info": { "title": "Test API", "version": "1.0.0" },
348 "paths": paths
349 })
350 .to_string()
351 }
352
353 fn spec_shell_with_components(
354 paths: serde_json::Value,
355 components: serde_json::Value,
356 ) -> String {
357 json!({
358 "openapi": "3.0.3",
359 "info": { "title": "Test API", "version": "1.0.0" },
360 "paths": paths,
361 "components": components
362 })
363 .to_string()
364 }
365
366 #[test]
369 fn version_3_0_3_accepted() {
370 let spec = spec_shell(json!({}));
371 let result = parse_spec(&spec);
372 assert!(result.is_ok(), "3.0.3 should be accepted");
373 }
374
375 #[test]
376 fn version_3_0_0_accepted() {
377 let spec = json!({
378 "openapi": "3.0.0",
379 "info": { "title": "Test", "version": "1.0.0" },
380 "paths": {}
381 })
382 .to_string();
383 let result = parse_spec(&spec);
384 assert!(result.is_ok(), "3.0.0 should be accepted");
385 }
386
387 #[test]
388 fn version_3_1_accepted() {
389 let spec = json!({
390 "openapi": "3.1.0",
391 "info": { "title": "Test", "version": "1.0.0" },
392 "paths": {}
393 })
394 .to_string();
395 let result = parse_spec(&spec);
396 assert!(result.is_ok(), "3.1.0 should be accepted");
397 }
398
399 #[test]
400 fn version_2_0_rejected() {
401 let spec = json!({
403 "swagger": "2.0",
404 "info": { "title": "Test", "version": "1.0.0" },
405 "paths": {}
406 })
407 .to_string();
408 let result = parse_spec(&spec);
409 assert!(result.is_err(), "2.0 should be rejected");
410 }
411
412 #[test]
415 fn extracts_single_get_operation() {
416 let spec = spec_shell(json!({
417 "/api/users": {
418 "get": {
419 "operationId": "api.users.index",
420 "summary": "List users",
421 "responses": { "200": { "description": "OK" } }
422 }
423 }
424 }));
425 let ops = parse_spec(&spec).unwrap();
426 assert_eq!(ops.len(), 1);
427 assert_eq!(ops[0].method, "GET");
428 assert_eq!(ops[0].path, "/api/users");
429 }
430
431 #[test]
432 fn extracts_multiple_operations() {
433 let spec = spec_shell(json!({
434 "/api/users": {
435 "post": {
436 "operationId": "api.users.store",
437 "responses": { "201": { "description": "Created" } }
438 }
439 },
440 "/api/users/{id}": {
441 "delete": {
442 "operationId": "api.users.destroy",
443 "responses": { "204": { "description": "Deleted" } }
444 }
445 }
446 }));
447 let ops = parse_spec(&spec).unwrap();
448 assert_eq!(ops.len(), 2);
449
450 let methods: Vec<&str> = ops.iter().map(|o| o.method.as_str()).collect();
451 assert!(methods.contains(&"POST"));
452 assert!(methods.contains(&"DELETE"));
453 }
454
455 #[test]
456 fn empty_paths_returns_empty_vec() {
457 let spec = spec_shell(json!({}));
458 let ops = parse_spec(&spec).unwrap();
459 assert!(ops.is_empty());
460 }
461
462 #[test]
465 fn tool_name_from_operation_id_dots_to_underscores() {
466 let spec = spec_shell(json!({
467 "/api/users": {
468 "get": {
469 "operationId": "api.users.index",
470 "responses": { "200": { "description": "OK" } }
471 }
472 }
473 }));
474 let ops = parse_spec(&spec).unwrap();
475 assert_eq!(ops[0].tool_name, "api_users_index");
476 }
477
478 #[test]
479 fn tool_name_generated_when_no_operation_id() {
480 let spec = spec_shell(json!({
481 "/api/users": {
482 "get": {
483 "responses": { "200": { "description": "OK" } }
484 }
485 }
486 }));
487 let ops = parse_spec(&spec).unwrap();
488 assert_eq!(ops[0].tool_name, "get_api_users");
489 }
490
491 #[test]
492 fn tool_name_mixed_with_and_without_operation_id() {
493 let spec = spec_shell(json!({
494 "/api/users": {
495 "get": {
496 "operationId": "api.users.index",
497 "responses": { "200": { "description": "OK" } }
498 }
499 },
500 "/api/posts": {
501 "get": {
502 "responses": { "200": { "description": "OK" } }
503 }
504 }
505 }));
506 let ops = parse_spec(&spec).unwrap();
507 assert_eq!(ops.len(), 2);
508
509 let names: Vec<&str> = ops.iter().map(|o| o.tool_name.as_str()).collect();
510 assert!(names.contains(&"api_users_index"));
511 assert!(names.contains(&"get_api_posts"));
512 }
513
514 #[test]
517 fn extracts_path_parameter() {
518 let spec = spec_shell(json!({
519 "/api/users/{id}": {
520 "get": {
521 "operationId": "api.users.show",
522 "parameters": [
523 {
524 "name": "id",
525 "in": "path",
526 "required": true,
527 "schema": { "type": "integer" }
528 }
529 ],
530 "responses": { "200": { "description": "OK" } }
531 }
532 }
533 }));
534 let ops = parse_spec(&spec).unwrap();
535 assert_eq!(ops[0].parameters.len(), 1);
536 assert_eq!(ops[0].parameters[0].name, "id");
537 assert_eq!(ops[0].parameters[0].location, ParamLocation::Path);
538 assert!(ops[0].parameters[0].required);
539 }
540
541 #[test]
542 fn extracts_query_parameter() {
543 let spec = spec_shell(json!({
544 "/api/users": {
545 "get": {
546 "operationId": "api.users.index",
547 "parameters": [
548 {
549 "name": "page",
550 "in": "query",
551 "required": false,
552 "schema": { "type": "integer" }
553 }
554 ],
555 "responses": { "200": { "description": "OK" } }
556 }
557 }
558 }));
559 let ops = parse_spec(&spec).unwrap();
560 assert_eq!(ops[0].parameters.len(), 1);
561 assert_eq!(ops[0].parameters[0].name, "page");
562 assert_eq!(ops[0].parameters[0].location, ParamLocation::Query);
563 assert!(!ops[0].parameters[0].required);
564 }
565
566 #[test]
567 fn no_parameters_returns_empty_vec() {
568 let spec = spec_shell(json!({
569 "/api/health": {
570 "get": {
571 "operationId": "health.check",
572 "responses": { "200": { "description": "OK" } }
573 }
574 }
575 }));
576 let ops = parse_spec(&spec).unwrap();
577 assert!(ops[0].parameters.is_empty());
578 }
579
580 #[test]
581 fn merges_path_level_and_operation_level_parameters() {
582 let spec = spec_shell(json!({
583 "/api/users/{id}": {
584 "parameters": [
585 {
586 "name": "id",
587 "in": "path",
588 "required": true,
589 "schema": { "type": "integer" }
590 }
591 ],
592 "get": {
593 "operationId": "api.users.show",
594 "parameters": [
595 {
596 "name": "include",
597 "in": "query",
598 "required": false,
599 "schema": { "type": "string" }
600 }
601 ],
602 "responses": { "200": { "description": "OK" } }
603 }
604 }
605 }));
606 let ops = parse_spec(&spec).unwrap();
607 assert_eq!(ops[0].parameters.len(), 2);
608
609 let names: Vec<&str> = ops[0].parameters.iter().map(|p| p.name.as_str()).collect();
610 assert!(names.contains(&"id"));
611 assert!(names.contains(&"include"));
612 }
613
614 #[test]
617 fn extracts_json_request_body_schema() {
618 let spec = spec_shell(json!({
619 "/api/users": {
620 "post": {
621 "operationId": "api.users.store",
622 "requestBody": {
623 "required": true,
624 "content": {
625 "application/json": {
626 "schema": {
627 "type": "object",
628 "properties": {
629 "name": { "type": "string" },
630 "email": { "type": "string" }
631 },
632 "required": ["name", "email"]
633 }
634 }
635 }
636 },
637 "responses": { "201": { "description": "Created" } }
638 }
639 }
640 }));
641 let ops = parse_spec(&spec).unwrap();
642 let body = ops[0].request_body_schema.as_ref().unwrap();
643 let props = body.get("properties").unwrap();
644 assert!(props.get("name").is_some());
645 assert!(props.get("email").is_some());
646 }
647
648 #[test]
649 fn get_has_no_request_body() {
650 let spec = spec_shell(json!({
651 "/api/users": {
652 "get": {
653 "operationId": "api.users.index",
654 "responses": { "200": { "description": "OK" } }
655 }
656 }
657 }));
658 let ops = parse_spec(&spec).unwrap();
659 assert!(ops[0].request_body_schema.is_none());
660 }
661
662 #[test]
665 fn resolves_request_body_schema_ref() {
666 let spec = spec_shell_with_components(
667 json!({
668 "/api/users": {
669 "post": {
670 "operationId": "api.users.store",
671 "requestBody": {
672 "required": true,
673 "content": {
674 "application/json": {
675 "schema": {
676 "$ref": "#/components/schemas/CreateUserRequest"
677 }
678 }
679 }
680 },
681 "responses": { "201": { "description": "Created" } }
682 }
683 }
684 }),
685 json!({
686 "schemas": {
687 "CreateUserRequest": {
688 "type": "object",
689 "properties": {
690 "name": { "type": "string" },
691 "email": { "type": "string" }
692 },
693 "required": ["name", "email"]
694 }
695 }
696 }),
697 );
698 let ops = parse_spec(&spec).unwrap();
699 let body = ops[0].request_body_schema.as_ref().unwrap();
700 let props = body.get("properties").unwrap();
701 assert!(props.get("name").is_some());
702 assert!(props.get("email").is_some());
703 }
704
705 #[test]
706 fn unresolvable_ref_degrades_gracefully() {
707 let spec = spec_shell_with_components(
710 json!({
711 "/api/users": {
712 "post": {
713 "operationId": "api.users.store",
714 "requestBody": {
715 "required": true,
716 "content": {
717 "application/json": {
718 "schema": {
719 "$ref": "#/components/schemas/NonExistent"
720 }
721 }
722 }
723 },
724 "responses": { "201": { "description": "Created" } }
725 }
726 }
727 }),
728 json!({
729 "schemas": {}
730 }),
731 );
732 let ops = parse_spec(&spec).unwrap();
733 assert_eq!(ops.len(), 1);
734 assert!(ops[0].request_body_schema.is_none());
736 }
737
738 #[test]
739 fn resolves_parameter_schema_ref() {
740 let spec = spec_shell_with_components(
741 json!({
742 "/api/users": {
743 "get": {
744 "operationId": "api.users.index",
745 "parameters": [
746 {
747 "$ref": "#/components/parameters/PageParam"
748 }
749 ],
750 "responses": { "200": { "description": "OK" } }
751 }
752 }
753 }),
754 json!({
755 "parameters": {
756 "PageParam": {
757 "name": "page",
758 "in": "query",
759 "required": false,
760 "schema": { "type": "integer" }
761 }
762 }
763 }),
764 );
765 let ops = parse_spec(&spec).unwrap();
766 assert_eq!(ops[0].parameters.len(), 1);
767 assert_eq!(ops[0].parameters[0].name, "page");
768 assert_eq!(ops[0].parameters[0].location, ParamLocation::Query);
769 }
770
771 #[test]
774 fn description_from_summary_and_description() {
775 let spec = spec_shell(json!({
776 "/api/users": {
777 "get": {
778 "operationId": "api.users.index",
779 "summary": "List users",
780 "description": "Returns all users",
781 "responses": { "200": { "description": "OK" } }
782 }
783 }
784 }));
785 let ops = parse_spec(&spec).unwrap();
786 assert_eq!(ops[0].description, "List users - Returns all users");
787 }
788
789 #[test]
790 fn description_from_summary_only() {
791 let spec = spec_shell(json!({
792 "/api/users": {
793 "get": {
794 "operationId": "api.users.index",
795 "summary": "List users",
796 "responses": { "200": { "description": "OK" } }
797 }
798 }
799 }));
800 let ops = parse_spec(&spec).unwrap();
801 assert_eq!(ops[0].description, "List users");
802 }
803
804 #[test]
805 fn description_fallback_to_tool_name() {
806 let spec = spec_shell(json!({
807 "/api/users": {
808 "get": {
809 "operationId": "api.users.index",
810 "responses": { "200": { "description": "OK" } }
811 }
812 }
813 }));
814 let ops = parse_spec(&spec).unwrap();
815 assert_eq!(ops[0].description, "api_users_index");
816 }
817
818 #[test]
821 fn x_mcp_tool_name_overrides_operation_id() {
822 let spec = spec_shell(json!({
823 "/api/users": {
824 "get": {
825 "operationId": "api.users.index",
826 "summary": "List users",
827 "x-mcp-tool-name": "list_all_users",
828 "responses": { "200": { "description": "OK" } }
829 }
830 }
831 }));
832 let ops = parse_spec(&spec).unwrap();
833 assert_eq!(ops[0].tool_name, "list_all_users");
834 }
835
836 #[test]
837 fn x_mcp_description_overrides_summary() {
838 let spec = spec_shell(json!({
839 "/api/users": {
840 "get": {
841 "operationId": "api.users.index",
842 "summary": "List users",
843 "x-mcp-description": "Retrieve all user records with pagination",
844 "responses": { "200": { "description": "OK" } }
845 }
846 }
847 }));
848 let ops = parse_spec(&spec).unwrap();
849 assert_eq!(
850 ops[0].description,
851 "Retrieve all user records with pagination"
852 );
853 }
854
855 #[test]
856 fn x_mcp_hidden_excludes_operation() {
857 let spec = spec_shell(json!({
858 "/api/users": {
859 "get": {
860 "operationId": "api.users.index",
861 "summary": "List users",
862 "responses": { "200": { "description": "OK" } }
863 }
864 },
865 "/api/internal/health": {
866 "get": {
867 "operationId": "internal.health",
868 "summary": "Health check",
869 "x-mcp-hidden": true,
870 "responses": { "200": { "description": "OK" } }
871 }
872 }
873 }));
874 let ops = parse_spec(&spec).unwrap();
875 assert_eq!(ops.len(), 1);
876 assert_eq!(ops[0].tool_name, "api_users_index");
877 }
878
879 #[test]
880 fn x_mcp_hint_extracted() {
881 let spec = spec_shell(json!({
882 "/api/users": {
883 "get": {
884 "operationId": "api.users.index",
885 "summary": "List users",
886 "x-mcp-hint": "Use page and per_page query params for pagination",
887 "responses": { "200": { "description": "OK" } }
888 }
889 }
890 }));
891 let ops = parse_spec(&spec).unwrap();
892 assert_eq!(
893 ops[0].hint.as_deref(),
894 Some("Use page and per_page query params for pagination")
895 );
896 }
897
898 #[test]
899 fn x_mcp_fallback_without_extensions() {
900 let spec = spec_shell(json!({
901 "/api/users": {
902 "get": {
903 "operationId": "api.users.index",
904 "summary": "List users",
905 "responses": { "200": { "description": "OK" } }
906 }
907 }
908 }));
909 let ops = parse_spec(&spec).unwrap();
910 assert_eq!(ops[0].tool_name, "api_users_index");
911 assert_eq!(ops[0].description, "List users");
912 assert!(ops[0].hint.is_none());
913 }
914
915 #[test]
916 fn x_mcp_partial_extensions() {
917 let spec = spec_shell(json!({
918 "/api/users": {
919 "get": {
920 "operationId": "api.users.index",
921 "summary": "List users",
922 "x-mcp-tool-name": "fetch_users",
923 "responses": { "200": { "description": "OK" } }
924 }
925 }
926 }));
927 let ops = parse_spec(&spec).unwrap();
928 assert_eq!(ops[0].tool_name, "fetch_users");
929 assert_eq!(ops[0].description, "List users");
930 assert!(ops[0].hint.is_none());
931 }
932}