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};
use v2rmp::core::postgis_cpp::{PostGisCppRequest, run_postgis_cpp};
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 or OSM.",
"inputSchema": {
"type": "object",
"properties": {
"source": { "type": "string", "enum": ["overture", "osm"] },
"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 using classical algorithms.",
"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" }
},
"required": ["map_path", "output_route"]
}
},
{
"name": "v2rmp_postgis_cpp",
"description": "Solve Chinese Postman Problem directly from a PostGIS road_edges table.",
"inputSchema": {
"type": "object",
"properties": {
"bbox_min_lon": { "type": "number" },
"bbox_min_lat": { "type": "number" },
"bbox_max_lon": { "type": "number" },
"bbox_max_lat": { "type": "number" },
"road_classes": { "type": "array", "items": { "type": "string" } },
"oneway_mode": { "type": "string", "enum": ["ignore", "respect", "reverse"] },
"left_turn_penalty": { "type": "number" },
"right_turn_penalty": { "type": "number" },
"u_turn_penalty": { "type": "number" },
"depot_lat": { "type": "number" },
"depot_lon": { "type": "number" },
"database_url": { "type": "string" },
"table_name": { "type": "string" },
"output_path": { "type": "string" }
},
"required": ["bbox_min_lon", "bbox_min_lat", "bbox_max_lon", "bbox_max_lat"]
}
},
{
"name": "v2rmp_neural_optimize",
"description": "Optimize a route using a Neural Network model (ONNX). Best for complex VRP problems with capacity constraints.",
"inputSchema": {
"type": "object",
"properties": {
"model_path": { "type": "string", "description": "Path to the .onnx model file" },
"locations": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"description": "List of [lat, lon] coordinates. Index 0 is the depot."
},
"demands": { "type": "array", "items": { "type": "number" }, "description": "Demands for each location (0 for depot)" },
"capacity": { "type": "number", "description": "Vehicle capacity" }
},
"required": ["model_path", "locations", "demands", "capacity"]
}
}
]
})),
"resources/list" => respond(id.as_ref(), json!({ "resources": [] })),
"resources/templates/list" => respond(id.as_ref(), json!({ "resourceTemplates": [] })),
"notifications/initialized" => None,
"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()),
}
}
"ping" => respond(id.as_ref(), json!({})),
_ => respond_error(id.as_ref(), -32601, format!("Method not found: {}", method)),
}
}
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,
_ => 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(),
};
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,
};
let res = run_optimize(&req)?;
Ok(json!({ "content": [{ "type": "text", "text": format!("Optimized: {:.2} km", res.total_distance_km) }], "isError": false }))
}
"v2rmp_postgis_cpp" => {
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 road_classes: Vec<String> = arguments
.get("road_classes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let oneway_mode = match arguments
.get("oneway_mode")
.and_then(Value::as_str)
.unwrap_or("respect")
{
"ignore" => v2rmp::core::postgis_cpp::OneWayMode::Ignore,
"reverse" => v2rmp::core::postgis_cpp::OneWayMode::Reverse,
_ => v2rmp::core::postgis_cpp::OneWayMode::Respect,
};
let req = PostGisCppRequest {
bbox: [
arguments.get("bbox_min_lon").and_then(Value::as_f64).context("Missing bbox_min_lon")?,
arguments.get("bbox_min_lat").and_then(Value::as_f64).context("Missing bbox_min_lat")?,
arguments.get("bbox_max_lon").and_then(Value::as_f64).context("Missing bbox_max_lon")?,
arguments.get("bbox_max_lat").and_then(Value::as_f64).context("Missing bbox_max_lat")?,
],
road_classes,
oneway_mode,
turn_penalties: v2rmp::core::postgis_cpp::TurnPenalties {
left: arguments.get("left_turn_penalty").and_then(Value::as_f64).unwrap_or(50.0),
right: arguments.get("right_turn_penalty").and_then(Value::as_f64).unwrap_or(0.0),
u_turn: arguments.get("u_turn_penalty").and_then(Value::as_f64).unwrap_or(500.0),
},
depot,
database_url: arguments.get("database_url").and_then(Value::as_str).map(|s| s.to_string()),
table_name: arguments.get("table_name").and_then(Value::as_str).map(|s| s.to_string()),
output_path: arguments.get("output_path").and_then(Value::as_str).map(|s| s.to_string()),
};
let res = run_postgis_cpp(&req)?;
let json_text = serde_json::to_string_pretty(&res)?;
Ok(json!({ "content": [{ "type": "text", "text": json_text }], "isError": false }))
}
"v2rmp_neural_optimize" => {
use v2rmp::core::neural_routing::{NeuralRouteRequest, solve_neural};
let locations_raw = arguments.get("locations").and_then(Value::as_array).context("Missing locations")?;
let mut locations = Vec::with_capacity(locations_raw.len());
for loc in locations_raw {
let coords = loc.as_array().context("Invalid coordinate format")?;
let lat = coords[0].as_f64().context("Invalid latitude")?;
let lon = coords[1].as_f64().context("Invalid longitude")?;
locations.push([lat, lon]);
}
let demands = arguments.get("demands").and_then(Value::as_array)
.context("Missing demands")?
.iter().filter_map(|v| v.as_f64()).collect();
let req = NeuralRouteRequest {
model_path: arguments.get("model_path").and_then(Value::as_str).context("Missing model_path")?.to_string(),
locations,
demands,
capacity: arguments.get("capacity").and_then(Value::as_f64).unwrap_or(1.0),
};
let res = solve_neural(&req)?;
Ok(json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&res)?
}],
"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 } }))
}