Skip to main content

ferro_api_mcp/
spec.rs

1use openapiv3::{OpenAPI, Operation, Parameter, ReferenceOr, RequestBody};
2use serde_json::Value;
3
4use crate::error::Error;
5use crate::types::{ApiOperation, ApiParam, ParamLocation};
6
7/// Metadata extracted from an OpenAPI spec's info and servers sections.
8pub struct SpecMetadata {
9    /// The API title from `info.title`, or "API" if missing.
10    pub title: String,
11    /// The first server URL from `servers[0].url`, if present.
12    pub server_url: Option<String>,
13}
14
15/// Extract metadata (title, server URL) from an OpenAPI spec JSON string.
16///
17/// This is a lightweight parse that only reads the `info` and `servers`
18/// sections, independent of the full `parse_spec` operation.
19pub 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
33/// Fetch an OpenAPI spec from a URL.
34///
35/// Returns the raw JSON string for parsing with `parse_spec`.
36/// Categorizes fetch errors for actionable diagnostics:
37/// connection refused, timeout, decode errors, non-2xx status, non-JSON content.
38pub 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    // Quick JSON validity check before returning
56    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
65/// Categorize a reqwest error into a specific diagnostic message.
66fn 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
80/// Parse an OpenAPI 3.0.x JSON spec into a list of API operations.
81///
82/// Validates the spec version (only 3.0.x supported), extracts all
83/// operations from paths, resolves `$ref` references, and builds
84/// `ApiOperation` for each (method, path, operation) tuple.
85pub 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                // Skip operations marked as hidden via x-mcp-hidden
111                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                // Extract x-mcp overrides
121                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                // Use x-mcp values as overrides with existing behavior as fallback
138                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
162/// Generate a tool name from an operation ID or method+path.
163///
164/// If `operation_id` is present, dots are replaced with underscores.
165/// Otherwise, a name is generated from `{method}_{sanitized_path}`.
166fn 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
180/// Build a description from operation summary and description fields.
181///
182/// Priority: "summary - description" > "summary" > tool_name fallback.
183fn 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
192/// Extract parameters from both operation-level and path-level parameter lists.
193///
194/// Resolves `$ref` references to `#/components/parameters/...`. Operation-level
195/// parameters are processed first, then path-level parameters that don't
196/// duplicate names already seen.
197fn 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    // Operation-level parameters take precedence
206    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    // Path-level parameters (skip if already defined at operation level)
214    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
225/// Resolve a single parameter reference to an `ApiParam`.
226fn 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, // Skip cookie params
238    };
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
251/// Look up a parameter in `#/components/parameters/`.
252fn 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
266/// Extract the JSON Schema value from a parameter's schema-or-content.
267fn 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                // For parameter schemas that are $ref, serialize the schema type as-is
275                Value::Object(Default::default())
276            }
277        },
278        openapiv3::ParameterSchemaOrContent::Content(_) => Value::Object(Default::default()),
279    }
280}
281
282/// Extract the request body JSON schema from an operation.
283///
284/// Looks for `application/json` content type and resolves `$ref` in the
285/// schema. Returns `None` if no request body, no JSON content, or
286/// unresolvable reference.
287fn 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
307/// Resolve a `$ref` pointing to `#/components/schemas/...` into a JSON Value.
308///
309/// Looks up the schema name in `spec.components.schemas`, serializes it
310/// via serde. Returns `None` and logs a warning for unresolvable refs.
311fn 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    // Helper: minimal valid OpenAPI 3.0.3 spec shell
344    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    // ── 1. Version validation ──────────────────────────────────────
367
368    #[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        // openapiv3 cannot parse Swagger 2.0 at all, so we expect a parse error
402        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    // ── 2. Operation extraction ────────────────────────────────────
413
414    #[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    // ── 3. Tool naming ─────────────────────────────────────────────
463
464    #[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    // ── 4. Parameter extraction ────────────────────────────────────
515
516    #[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    // ── 5. Request body extraction ─────────────────────────────────
615
616    #[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    // ── 6. $ref resolution ─────────────────────────────────────────
663
664    #[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        // An unresolvable $ref should NOT fail the entire parse.
708        // The operation should still be extracted, with request_body_schema = None.
709        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        // Body schema should be None since the ref couldn't be resolved
735        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    // ── 7. Description extraction ──────────────────────────────────
772
773    #[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    // ── 8. x-mcp vendor extensions ───────────────────────────────
819
820    #[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}