use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use url::Url;
use ferro_api_mcp::http::HttpClient;
use ferro_api_mcp::schema::build_input_schema;
use ferro_api_mcp::server::McpServer;
use ferro_api_mcp::service::ApiMcpService;
use ferro_api_mcp::spec;
#[derive(Parser, Debug)]
#[command(name = "ferro-api-mcp", version)]
struct Cli {
#[arg(long)]
spec_url: String,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long, default_value = "info")]
log_level: String,
#[arg(long)]
dry_run: bool,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&cli.log_level)),
)
.init();
tracing::debug!(?cli, "parsed CLI arguments");
if let Err(err) = run(cli).await {
eprintln!("Error: {err}");
std::process::exit(1);
}
}
async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
let spec_json = spec::fetch_spec(&cli.spec_url)
.await
.map_err(|e| format_spec_fetch_error(&cli.spec_url, &e.to_string()))?;
eprintln!("Fetched spec: {} bytes", spec_json.len());
let mut operations =
spec::parse_spec(&spec_json).map_err(|e| format!("Failed to parse OpenAPI spec: {e}"))?;
if operations.is_empty() {
eprintln!("Warning: spec parsed successfully but contains no operations. Check that the spec has paths defined.");
}
let metadata = spec::extract_metadata(&spec_json)
.map_err(|e| format!("Failed to extract spec metadata: {e}"))?;
let base_url = resolve_base_url(&cli.base_url, &metadata.server_url, &cli.spec_url)?;
check_api_connectivity(&base_url).await;
for op in &mut operations {
op.input_schema = build_input_schema(&op.parameters, op.request_body_schema.as_ref());
}
let version = env!("CARGO_PKG_VERSION");
eprintln!();
eprintln!("ferro-api-mcp v{version}");
eprintln!("API: {}", metadata.title);
eprintln!("Base URL: {base_url}");
eprintln!("Tools: {} registered", operations.len());
if cli.dry_run {
eprintln!();
eprintln!("Tools:");
for op in &operations {
let first_line = op.description.lines().next().unwrap_or(&op.tool_name);
eprintln!(" - {}: {first_line}", op.tool_name);
}
eprintln!();
eprintln!("Dry run complete. {} tools validated.", operations.len());
return Ok(());
}
let http_client = Arc::new(HttpClient::new(base_url, cli.api_key));
let service = ApiMcpService::new(metadata.title, operations, http_client);
let server = McpServer::new(service);
server.run().await?;
Ok(())
}
async fn check_api_connectivity(base_url: &Url) {
let result = reqwest::Client::new()
.head(base_url.as_str())
.timeout(Duration::from_secs(5))
.send()
.await;
if let Err(e) = result {
eprintln!(
"Warning: API at {base_url} is not reachable. Tools will fail until the API is available. ({e})"
);
}
}
fn format_spec_fetch_error(url: &str, error: &str) -> String {
let lower = error.to_lowercase();
if lower.contains("connection refused") {
format!("Cannot connect to {url}. Is the server running?")
} else if lower.contains("timed out") || lower.contains("timeout") {
format!("Request to {url} timed out. Check network connectivity.")
} else if lower.contains("dns") || lower.contains("resolve") || lower.contains("no such host") {
format!("Cannot resolve hostname in {url}. Check the URL.")
} else if lower.contains("http ") && lower.contains("expected 200") {
format!("Spec URL returned {error}")
} else if lower.contains("not valid json") {
"Spec URL returned non-JSON content. Expected an OpenAPI 3.0.x JSON document.".to_string()
} else {
format!("Failed to fetch OpenAPI spec from {url}: {error}")
}
}
fn resolve_base_url(
cli_base_url: &Option<String>,
spec_server_url: &Option<String>,
spec_url: &str,
) -> Result<Url, Box<dyn std::error::Error>> {
if let Some(url_str) = cli_base_url {
return Url::parse(url_str)
.map_err(|e| format!("Invalid --base-url '{url_str}': {e}").into());
}
if let Some(url_str) = spec_server_url {
if let Ok(url) = Url::parse(url_str) {
return Ok(url);
}
}
let parsed = Url::parse(spec_url)
.map_err(|e| format!("Cannot determine base URL from spec URL '{spec_url}': {e}"))?;
Ok(parsed.origin().unicode_serialization().parse()?)
}