mrapids 0.1.31

Your OpenAPI, but executable
Documentation
//! HTTP server startup and routing

use super::AppState;
use crate::core::mcp::McpServer;
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;

/// Start the HTTP server
pub async fn start_http_server(
    bind: String,
    port: u16,
    api_key: Option<String>,
    policy: Option<PathBuf>,
    spec: Option<PathBuf>,
    allow_localhost: bool,
    debug: bool,
) -> anyhow::Result<()> {
    // Set localhost env var for subprocess calls
    if allow_localhost {
        std::env::set_var("MRAPIDS_ALLOW_LOCALHOST", "true");
    }

    // Capture display strings before moving into McpServer
    let spec_display = spec
        .as_ref()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "auto-detect".to_string());
    let policy_display = policy
        .as_ref()
        .map(|p| p.display().to_string())
        .unwrap_or_else(|| "permissive".to_string());
    let has_api_key = api_key.is_some();

    // Create MCP server (holds all state: policy, index, spec, etc.)
    let mut server = McpServer::new(debug, policy, spec);
    // Initialize: load index store, audit log, security schemes
    if let Err(e) = server.initialize() {
        eprintln!("Warning: initialization error: {} (continuing)", e);
    }

    let state = AppState {
        server: Arc::new(Mutex::new(server)),
        api_key,
        start_time: std::time::Instant::now(),
        allow_localhost,
    };

    // Build router
    let app = Router::new()
        .route("/health", get(handle_health))
        .route("/api/operations", get(handle_list_operations))
        .route("/api/find", post(handle_find))
        .route("/api/execute", post(handle_execute))
        .with_state(state);

    let addr: SocketAddr = format!("{}:{}", bind, port).parse()?;
    eprintln!("🚀 mrapids HTTP server starting on http://{}", addr);
    if has_api_key {
        eprintln!("🔒 API key authentication enabled");
    } else {
        eprintln!("⚠️  No API key configured — accepting all requests");
    }
    eprintln!("   Spec: {}", spec_display);
    eprintln!("   Policy: {}", policy_display);
    eprintln!(
        "   Base URL: {}",
        std::env::var("API_BASE_URL").unwrap_or_else(|_| "from spec/config".to_string())
    );
    eprintln!(
        "   Localhost: {}",
        if allow_localhost {
            "allowed"
        } else {
            "blocked"
        }
    );
    eprintln!();

    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

// ─── Health ──────────────────────────────────────────────────────────────────

async fn handle_health(State(state): State<AppState>) -> Json<serde_json::Value> {
    let uptime = state.start_time.elapsed().as_secs();
    Json(serde_json::json!({
        "status": "ok",
        "version": env!("CARGO_PKG_VERSION"),
        "uptime_seconds": uptime,
    }))
}

// ─── Discovery ───────────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct FindRequest {
    query: String,
    method: Option<String>,
    limit: Option<usize>,
}

async fn handle_list_operations(State(state): State<AppState>) -> impl IntoResponse {
    // Use CLI subprocess for reliable listing (bypasses MCP search_depth logic)
    let exe_path = std::env::current_exe().unwrap_or_else(|_| "mrapids".into());
    let mut args = vec![
        "list".to_string(),
        "operations".to_string(),
        "--json".to_string(),
    ];

    {
        let server = state.server.lock().await;
        if let Ok(spec_path) = server.find_spec_file() {
            args.insert(2, spec_path.display().to_string()); // positional arg
        }
    }

    match tokio::process::Command::new(&exe_path)
        .args(&args)
        .output()
        .await
    {
        Ok(output) => {
            let stdout = String::from_utf8_lossy(&output.stdout);
            match serde_json::from_str::<serde_json::Value>(&stdout) {
                Ok(parsed) => (StatusCode::OK, Json(parsed)),
                Err(_) => {
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    (
                        StatusCode::INTERNAL_SERVER_ERROR,
                        Json(serde_json::json!({
                            "error": "list_failed",
                            "message": "Could not list operations from spec.",
                            "hint": if stderr.contains("No API specification") {
                                "No spec file found. Start server with --spec flag."
                            } else {
                                "Check that the spec file exists and is valid OpenAPI."
                            },
                            "stderr": stderr.to_string(),
                        })),
                    )
                }
            }
        }
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({
                "error": "subprocess_failed",
                "message": format!("Could not run list command: {}", e),
                "hint": "Check that mrapids binary is accessible.",
            })),
        ),
    }
}

async fn handle_find(
    State(state): State<AppState>,
    Json(req): Json<FindRequest>,
) -> impl IntoResponse {
    let exe_path = std::env::current_exe().unwrap_or_else(|_| "mrapids".into());

    // Get spec path for fallback
    let spec_path = {
        let server = state.server.lock().await;
        server
            .find_spec_file()
            .ok()
            .map(|p| p.display().to_string())
    };

    // Try 1: 'mrapids find' (uses index — fast, ranked)
    let mut args = vec![
        "find".to_string(),
        req.query.clone(),
        "--format".to_string(),
        "json".to_string(),
    ];
    if let Some(limit) = req.limit {
        args.push("--limit".to_string());
        args.push(limit.to_string());
    }

    if let Ok(output) = tokio::process::Command::new(&exe_path)
        .args(&args)
        .output()
        .await
    {
        let stdout = String::from_utf8_lossy(&output.stdout);
        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout) {
            // find returned valid JSON — check if it has results
            let has_results = parsed.get("count").and_then(|c| c.as_u64()).unwrap_or(0) > 0
                || parsed
                    .get("results")
                    .and_then(|r| r.as_array())
                    .map(|a| !a.is_empty())
                    .unwrap_or(false);
            if has_results {
                return (StatusCode::OK, Json(parsed));
            }
        }
    }

    // Try 2: 'mrapids explore' as fallback (uses spec directly — no index needed)
    if let Some(ref spec) = spec_path {
        let mut explore_args = vec![
            "explore".to_string(),
            req.query.clone(),
            "--format".to_string(),
            "json".to_string(),
        ];
        // explore takes spec as positional arg after keyword
        explore_args.push("--spec".to_string());
        explore_args.push(spec.clone());

        if let Ok(output) = tokio::process::Command::new(&exe_path)
            .args(&explore_args)
            .output()
            .await
        {
            let stdout = String::from_utf8_lossy(&output.stdout);
            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout) {
                // Check if explore returned actual results (not empty array)
                let is_empty = parsed.as_array().map(|a| a.is_empty()).unwrap_or(false);
                if !is_empty {
                    return (StatusCode::OK, Json(parsed));
                }
            }
        }
    }

    // Both failed — give the user actionable guidance
    (
        StatusCode::OK,
        Json(serde_json::json!({
            "results": [],
            "count": 0,
            "error": "search_failed",
            "message": format!("No operations found matching '{}'. Both index search and spec exploration returned empty.", req.query),
            "hint": "Try a different search term, or build the index: mrapids index build --spec <your-spec>",
            "possible_causes": [
                "Query too specific — try shorter keywords (e.g., 'portfolio' instead of 'get my portfolio data')",
                "No index built — run: mrapids index build --spec <your-spec>",
                "Spec has no descriptions — keyword search needs operation summaries to match",
            ],
            "spec_loaded": spec_path.is_some(),
        })),
    )
}

// ─── Execution ───────────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct ExecuteRequest {
    operation_id: String,
    params: Option<serde_json::Value>,
    body: Option<serde_json::Value>,
    select: Option<Vec<String>>,
    max_items: Option<usize>,
}

async fn handle_execute(
    State(state): State<AppState>,
    Json(req): Json<ExecuteRequest>,
) -> impl IntoResponse {
    // Build the args to pass to the mrapids run subprocess
    let exe_path = std::env::current_exe().unwrap_or_else(|_| "mrapids".into());

    let mut args = vec![
        "run".to_string(),
        req.operation_id.clone(),
        "--json-output".to_string(),
        "--redact".to_string(),
    ];

    // Allow localhost if configured
    if state.allow_localhost {
        args.push("--allow-localhost".to_string());
    }

    // Add spec if server has one configured
    {
        let server = state.server.lock().await;
        if let Ok(spec_path) = server.find_spec_file() {
            args.push("--spec".to_string());
            args.push(spec_path.display().to_string());
        }
    }

    // Add params
    if let Some(params) = &req.params {
        if let Some(obj) = params.as_object() {
            for (key, value) in obj {
                let value_str = match value {
                    serde_json::Value::String(s) => s.clone(),
                    _ => value.to_string(),
                };
                args.push("--param".to_string());
                args.push(format!("{}={}", key, value_str));
            }
        }
    }

    // Add body
    if let Some(body) = &req.body {
        args.push("--data".to_string());
        args.push(body.to_string());
    }

    // Execute subprocess (no lock held during I/O)
    let output = match tokio::process::Command::new(&exe_path)
        .args(&args)
        .output()
        .await
    {
        Ok(output) => output,
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({
                    "error": "execution_failed",
                    "message": format!("Failed to execute operation '{}': {}", req.operation_id, e),
                    "hint": "Check that mrapids binary is accessible and the spec file exists.",
                    "operation_id": req.operation_id,
                })),
            );
        }
    };

    let stdout = String::from_utf8_lossy(&output.stdout);

    // Parse the JSON response
    match serde_json::from_str::<serde_json::Value>(&stdout) {
        Ok(response) => {
            let success = response
                .get("success")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            let status = if success {
                StatusCode::OK
            } else {
                StatusCode::BAD_REQUEST
            };
            (status, Json(response))
        }
        Err(_) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({
                    "error": "parse_failed",
                    "message": format!("Operation '{}' executed but response was not valid JSON.", req.operation_id),
                    "hint": if stderr.contains("not found") || stderr.contains("No operation") {
                        format!("Operation '{}' may not exist. Use /api/find or /api/operations to discover valid operations.", req.operation_id)
                    } else if stderr.contains("URL") || stderr.contains("url") || stderr.contains("base_url") {
                        "Base URL may not be configured. Start server with --base-url flag or set API_BASE_URL env var.".to_string()
                    } else if stderr.contains("localhost") || stderr.contains("loopback") {
                        "Localhost access may be blocked. Start server with --allow-localhost flag.".to_string()
                    } else {
                        "Check that the target API is running and the base URL is correct.".to_string()
                    },
                    "stderr": stderr.to_string(),
                    "raw": stdout.to_string(),
                })),
            )
        }
    }
}