Skip to main content

pmcp_server/tools/
schema_export.rs

1//! Schema discovery and export tool.
2//!
3//! Connects to a remote MCP server, discovers its tools, resources, and
4//! prompts, then returns the schemas as JSON or generates Rust type stubs.
5
6use async_trait::async_trait;
7use pmcp::types::ToolInfo;
8use pmcp::ToolHandler;
9use serde::Deserialize;
10use serde_json::{json, Value};
11
12use super::{create_tester, default_timeout, internal_err};
13use crate::util::to_pascal_case;
14
15/// Input parameters for the `schema_export` tool.
16#[derive(Deserialize)]
17struct SchemaExportInput {
18    /// MCP server URL to export schemas from.
19    url: String,
20    /// Output format: "json" (default) or "rust".
21    #[serde(default = "default_format")]
22    format: String,
23    /// Timeout in seconds (default: 30).
24    #[serde(default = "default_timeout")]
25    timeout: u64,
26}
27
28fn default_format() -> String {
29    "json".to_string()
30}
31
32/// Schema discovery and export tool.
33///
34/// Connects to a remote MCP server and exports discovered tool, resource,
35/// and prompt schemas in JSON or Rust type-stub format.
36pub struct SchemaExportTool;
37
38#[async_trait]
39impl ToolHandler for SchemaExportTool {
40    async fn handle(&self, args: Value, _extra: pmcp::RequestHandlerExtra) -> pmcp::Result<Value> {
41        let params: SchemaExportInput = serde_json::from_value(args)
42            .map_err(|e| pmcp::Error::validation(format!("Invalid arguments: {e}")))?;
43
44        if params.format != "json" && params.format != "rust" {
45            return Err(pmcp::Error::validation(format!(
46                "Unknown format '{}'. Available: json, rust",
47                params.format
48            )));
49        }
50
51        let mut tester = create_tester(&params.url, params.timeout)?;
52
53        // Initialize the connection.
54        tester.run_quick_test().await.map_err(internal_err)?;
55
56        // Explicitly load tools (run_quick_test only initializes, doesn't list tools).
57        let tools_result = tester.test_tools_list().await;
58        if tools_result.status == mcp_tester::TestStatus::Failed {
59            return Err(internal_err(
60                tools_result
61                    .error
62                    .unwrap_or_else(|| "failed to list tools".into()),
63            ));
64        }
65
66        let server_name = tester
67            .get_server_name()
68            .unwrap_or_else(|| "unknown".to_string());
69        let server_version = tester
70            .get_server_version()
71            .unwrap_or_else(|| "unknown".to_string());
72
73        let mut response = json!({
74            "server_name": server_name,
75            "server_version": server_version,
76            "format": params.format,
77        });
78
79        if params.format == "json" {
80            // JSON format: serialize all schemas.
81            let tools_value: Value = match tester.get_tools() {
82                Some(tools) => serde_json::to_value(tools).map_err(internal_err)?,
83                None => json!([]),
84            };
85            let resources_value: Value = match tester.list_resources().await {
86                Ok(res) => serde_json::to_value(&res.resources).map_err(internal_err)?,
87                Err(_) => json!([]),
88            };
89            let prompts_value: Value = match tester.list_prompts().await {
90                Ok(res) => serde_json::to_value(&res.prompts).map_err(internal_err)?,
91                Err(_) => json!([]),
92            };
93            response["tools"] = tools_value;
94            response["resources"] = resources_value;
95            response["prompts"] = prompts_value;
96        } else {
97            // Rust format: generate type stubs from tool schemas.
98            let rust_types = generate_rust_types(tester.get_tools());
99            response["rust_types"] = json!(rust_types);
100        }
101
102        Ok(response)
103    }
104
105    fn metadata(&self) -> Option<ToolInfo> {
106        Some(ToolInfo::new(
107            "schema_export",
108            Some(
109                "Connect to a remote MCP server and export its tool/resource/prompt schemas"
110                    .to_string(),
111            ),
112            json!({
113                "type": "object",
114                "properties": {
115                    "url": {
116                        "type": "string",
117                        "description": "MCP server URL to export schemas from"
118                    },
119                    "format": {
120                        "type": "string",
121                        "enum": ["json", "rust"],
122                        "description": "Output format (default: json)",
123                        "default": "json"
124                    },
125                    "timeout": {
126                        "type": "integer",
127                        "description": "Timeout in seconds",
128                        "default": 30
129                    }
130                },
131                "required": ["url"]
132            }),
133        ))
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Rust type generation from JSON Schema
139// ---------------------------------------------------------------------------
140
141/// Map a JSON Schema type string to its Rust equivalent.
142fn json_type_to_rust(json_type: &str) -> &str {
143    match json_type {
144        "string" => "String",
145        "number" => "f64",
146        "integer" => "i64",
147        "boolean" => "bool",
148        "array" => "Vec<serde_json::Value>",
149        "object" => "serde_json::Value",
150        _ => "serde_json::Value",
151    }
152}
153
154/// Generate Rust type stubs from discovered tool schemas.
155fn generate_rust_types(tools: Option<&Vec<pmcp::types::ToolInfo>>) -> String {
156    let tools = match tools {
157        Some(t) if !t.is_empty() => t,
158        _ => return "// No tools discovered -- no types to generate.\n".to_string(),
159    };
160
161    let mut output = String::from("use serde::Deserialize;\n\n");
162
163    for tool in tools {
164        let struct_name = format!("{}Input", to_pascal_case(&tool.name));
165        let properties = tool
166            .input_schema
167            .get("properties")
168            .and_then(|p| p.as_object());
169
170        let required: Vec<&str> = tool
171            .input_schema
172            .get("required")
173            .and_then(|r| r.as_array())
174            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
175            .unwrap_or_default();
176
177        // Check for camelCase field names (scoped per tool).
178        let has_camel_case = properties
179            .map(|props| props.keys().any(|k| k.chars().any(|c| c.is_uppercase())))
180            .unwrap_or(false);
181
182        output.push_str("#[derive(Deserialize)]\n");
183        if has_camel_case {
184            output.push_str("#[serde(rename_all = \"camelCase\")]\n");
185        }
186        output.push_str(&format!("pub struct {struct_name} {{\n"));
187
188        if let Some(props) = properties {
189            for (field_name, field_schema) in props {
190                let field_type_str = field_schema
191                    .get("type")
192                    .and_then(|t| t.as_str())
193                    .unwrap_or("object");
194                let rust_type = json_type_to_rust(field_type_str);
195
196                // Add doc comment from description if present.
197                if let Some(desc) = field_schema.get("description").and_then(|d| d.as_str()) {
198                    output.push_str(&format!("    /// {desc}\n"));
199                }
200
201                let is_required = required.contains(&field_name.as_str());
202                if is_required {
203                    output.push_str(&format!("    pub {field_name}: {rust_type},\n"));
204                } else {
205                    output.push_str(&format!("    pub {field_name}: Option<{rust_type}>,\n"));
206                }
207            }
208        }
209
210        output.push_str("}\n\n");
211    }
212
213    output
214}