v2rmp 0.4.9

rmpca — Route Optimization TUI
Documentation
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
use v2rmp::core::r2::R2Storage;
use v2rmp::core::extract::{ExtractRequest, ExtractSource, BBoxRequest, RoadClass};
use v2rmp::core::compile::{CompileRequest, run_compile};
use v2rmp::core::optimize::{OptimizeRequest, OnewayMode, run_optimize, TurnPenalties};

fn main() -> Result<()> {
    let stdin = io::stdin();
    let mut stdout = io::stdout();

    for line in stdin.lock().lines() {
        let line = line?;
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let Ok(message) = serde_json::from_str::<Value>(trimmed) else {
            continue;
        };

        if let Some(response) = handle_message(message) {
            let payload = serde_json::to_string(&response)?;
            writeln!(stdout, "{}", payload)?;
            stdout.flush()?;
        }
    }

    Ok(())
}

fn handle_message(message: Value) -> Option<Value> {
    let method = message.get("method").and_then(Value::as_str)?;
    let id = message.get("id").cloned();

    match method {
        "initialize" => respond(id.as_ref(), json!({
            "protocolVersion": "2024-11-05",
            "serverInfo": {
                "name": "v2rmp-mcp-server",
                "version": env!("CARGO_PKG_VERSION"),
            },
            "capabilities": {
                "tools": {},
            }
        })),
        "tools/list" => respond(id.as_ref(), json!({
            "tools": [
                {
                    "name": "list_r2_bucket",
                    "description": "List all objects available in the configured Cloudflare R2 bucket.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "bucket": { "type": "string" },
                            "prefix": { "type": "string" }
                        }
                    }
                },
                {
                    "name": "upload_to_r2",
                    "description": "Upload a local file to the R2 bucket.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "local_path": { "type": "string" },
                            "r2_path": { "type": "string" },
                            "bucket": { "type": "string" }
                        },
                        "required": ["local_path", "r2_path"]
                    }
                },
                {
                    "name": "download_from_r2",
                    "description": "Download an object from the R2 bucket to a local file.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "r2_path": { "type": "string" },
                            "local_path": { "type": "string" },
                            "bucket": { "type": "string" }
                        },
                        "required": ["r2_path", "local_path"]
                    }
                },
                {
                    "name": "query_supabase",
                    "description": "Execute a SQL query against the Supabase database.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "query": { "type": "string" }
                        },
                        "required": ["query"]
                    }
                },
                {
                    "name": "v2rmp_extract",
                    "description": "Extract road network data from Overture, OSM, or Postgres.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "source": { "type": "string", "enum": ["overture", "osm", "postgres", "r2"] },
                            "min_lon": { "type": "number" },
                            "min_lat": { "type": "number" },
                            "max_lon": { "type": "number" },
                            "max_lat": { "type": "number" },
                            "output_path": { "type": "string" }
                        },
                        "required": ["source", "min_lon", "min_lat", "max_lon", "max_lat", "output_path"]
                    }
                },
                {
                    "name": "v2rmp_compile",
                    "description": "Compile GeoJSON into .rmp binary format with optional cleaning.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "input_geojson": { "type": "string" },
                            "output_rmp": { "type": "string" },
                            "remove_isolates": { "type": "boolean" }
                        },
                        "required": ["input_geojson", "output_rmp"]
                    }
                },
                {
                    "name": "v2rmp_optimize",
                    "description": "Optimize a route on an .rmp map.",
                    "inputSchema": {
                        "type": "object",
                        "properties": {
                            "map_path": { "type": "string" },
                            "u_turn_penalty": { "type": "number" },
                            "depot_lat": { "type": "number" },
                            "depot_lon": { "type": "number" },
                            "output_route": { "type": "string" },
                            "db_export_table": { "type": "string" }
                        },
                        "required": ["map_path", "output_route"]
                    }
                }
            ]
        })),
        "tools/call" => {
            let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
            match handle_tool_call(params) {
                Ok(result) => respond(id.as_ref(), result),
                Err(err) => respond_error(id.as_ref(), -32000, err.to_string()),
            }
        }
        _ => None,
    }
}

fn handle_tool_call(params: Value) -> Result<Value> {
    let name = params.get("name").and_then(Value::as_str).context("Missing tool name")?;
    let arguments = params.get("arguments").cloned().unwrap_or_else(|| json!({}));

    match name {
        "list_r2_bucket" => {
            let bucket = arguments.get("bucket").and_then(Value::as_str).unwrap_or("v2rmp");
            let prefix = arguments.get("prefix").and_then(Value::as_str);
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(async {
                let storage = R2Storage::from_env(bucket)?;
                let objects = storage.list_objects(prefix).await?;
                Ok(json!({ "content": [{ "type": "text", "text": format!("Objects:\n{}", objects.join("\n")) }], "isError": false }))
            })
        }
        "upload_to_r2" => {
            let local_path = arguments.get("local_path").and_then(Value::as_str).context("Missing local_path")?;
            let r2_path = arguments.get("r2_path").and_then(Value::as_str).context("Missing r2_path")?;
            let bucket = arguments.get("bucket").and_then(Value::as_str).unwrap_or("v2rmp");
            let data = std::fs::read(local_path)?;
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(async {
                let storage = R2Storage::from_env(bucket)?;
                storage.upload_object(r2_path, data).await?;
                Ok(json!({ "content": [{ "type": "text", "text": format!("Uploaded to {}", r2_path) }], "isError": false }))
            })
        }
        "download_from_r2" => {
            let r2_path = arguments.get("r2_path").and_then(Value::as_str).context("Missing r2_path")?;
            let local_path = arguments.get("local_path").and_then(Value::as_str).context("Missing local_path")?;
            let bucket = arguments.get("bucket").and_then(Value::as_str).unwrap_or("v2rmp");
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(async {
                let storage = R2Storage::from_env(bucket)?;
                let data = storage.download_object(r2_path).await?;
                std::fs::write(local_path, data)?;
                Ok(json!({ "content": [{ "type": "text", "text": format!("Downloaded to {}", local_path) }], "isError": false }))
            })
        }
        "query_supabase" => {
            let sql_query = arguments.get("query").and_then(Value::as_str).context("Missing SQL query")?;
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(async {
                dotenvy::dotenv().ok();
                let db_url = std::env::var("SUPABASE_DB_URL")?;
                let pool = sqlx::PgPool::connect(&db_url).await?;
                let rows = sqlx::query(sql_query).fetch_all(&pool).await?;
                let mut results = Vec::new();
                for row in rows {
                    use sqlx::{Column, Row, TypeInfo};
                    let mut res_row = serde_json::Map::new();
                    for col in row.columns() {
                        let name = col.name();
                        let val: Value = match col.type_info().name() {
                            "TEXT" | "VARCHAR" | "NAME" => row.get::<Option<String>, _>(name).map(Value::String).unwrap_or(Value::Null),
                            "INT4" | "INTEGER" => row.get::<Option<i32>, _>(name).map(|n| json!(n)).unwrap_or(Value::Null),
                            "INT8" | "BIGINT" => row.get::<Option<i64>, _>(name).map(|n| json!(n)).unwrap_or(Value::Null),
                            _ => json!("<type not displayed>"),
                        };
                        res_row.insert(name.to_string(), val);
                    }
                    results.push(Value::Object(res_row));
                }
                Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_string_pretty(&results)? }], "isError": false }))
            })
        }
        "v2rmp_extract" => {
            let source = match arguments.get("source").and_then(Value::as_str).unwrap_or("overture") {
                "osm" => ExtractSource::Osm, "postgres" => ExtractSource::Postgres, "r2" => ExtractSource::R2, _ => ExtractSource::Overture,
            };
            let req = ExtractRequest {
                source,
                bbox: BBoxRequest {
                    min_lon: arguments.get("min_lon").and_then(Value::as_f64).unwrap_or(0.0),
                    min_lat: arguments.get("min_lat").and_then(Value::as_f64).unwrap_or(0.0),
                    max_lon: arguments.get("max_lon").and_then(Value::as_f64).unwrap_or(0.0),
                    max_lat: arguments.get("max_lat").and_then(Value::as_f64).unwrap_or(0.0),
                },
                road_classes: RoadClass::all_vehicle(),
                output_path: arguments.get("output_path").and_then(Value::as_str).unwrap_or("out.geojson").to_string(),
                database_url: None, table_name: None, r2_bucket: None, r2_access_key_id: None, r2_secret_access_key: None, r2_endpoint: None,
            };
            let res = v2rmp::core::extract::run_extract(&req)?;
            Ok(json!({ "content": [{ "type": "text", "text": format!("Extracted {} nodes", res.nodes) }], "isError": false }))
        }
        "v2rmp_compile" => {
            let mut opts = v2rmp::core::clean::CleanOptions::default();
            opts.remove_isolates = arguments.get("remove_isolates").and_then(Value::as_bool).unwrap_or(true);
            let req = CompileRequest {
                input_geojson: arguments.get("input_geojson").and_then(Value::as_str).context("Missing input")?.to_string(),
                output_rmp: arguments.get("output_rmp").and_then(Value::as_str).context("Missing output")?.to_string(),
                compress: true, road_classes: vec![], clean_options: Some(opts),
            };
            let res = run_compile(&req)?;
            Ok(json!({ "content": [{ "type": "text", "text": format!("Compiled {} nodes", res.node_count) }], "isError": false }))
        }
        "v2rmp_optimize" => {
            let mut penalties = TurnPenalties::default();
            penalties.u_turn = arguments.get("u_turn_penalty").and_then(Value::as_f64).unwrap_or(10.0);
            let depot = if let (Some(lat), Some(lon)) = (arguments.get("depot_lat").and_then(Value::as_f64), arguments.get("depot_lon").and_then(Value::as_f64)) { Some((lat, lon)) } else { None };
            let req = OptimizeRequest {
                cache_file: arguments.get("map_path").and_then(Value::as_str).context("Missing map")?.to_string(),
                route_file: Some(arguments.get("output_route").and_then(Value::as_str).context("Missing output")?.to_string()),
                turn_penalties: penalties, depot, oneway_mode: OnewayMode::Respect,
                database_url: std::env::var("SUPABASE_DB_URL").ok(),
                table_name: arguments.get("db_export_table").and_then(Value::as_str).map(|s| s.to_string()),
            };
            let res = run_optimize(&req)?;
            Ok(json!({ "content": [{ "type": "text", "text": format!("Optimized: {:.2} km", res.total_distance_km) }], "isError": false }))
        }
        _ => anyhow::bail!("Tool not found"),
    }
}

fn respond(id: Option<&Value>, result: Value) -> Option<Value> {
    id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "result": result }))
}

fn respond_error(id: Option<&Value>, code: i64, message: String) -> Option<Value> {
    id.map(|id| json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }))
}