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 } }))
}