use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
const DEFAULT_PROTOCOL_VERSION: &str = "2024-11-05";
fn main() {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = stdout.lock();
for line in stdin.lock().lines() {
let Ok(line) = line else { break }; let line = line.trim();
if line.is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
send(
&mut out,
&error_obj(Value::Null, -32700, &format!("parse error: {e}")),
);
continue;
}
};
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(Value::Null);
match dispatch(method, params) {
Outcome::Reply(result) => {
if let Some(id) = id {
send(
&mut out,
&json!({"jsonrpc": "2.0", "id": id, "result": result}),
);
}
}
Outcome::Err(code, message) => {
if let Some(id) = id {
send(&mut out, &error_obj(id, code, &message));
}
}
Outcome::Silent => {} }
}
}
enum Outcome {
Reply(Value),
Err(i64, String),
Silent,
}
fn dispatch(method: &str, params: Value) -> Outcome {
match method {
"initialize" => {
let version = params
.get("protocolVersion")
.and_then(Value::as_str)
.unwrap_or(DEFAULT_PROTOCOL_VERSION)
.to_string();
Outcome::Reply(json!({
"protocolVersion": version,
"capabilities": { "tools": {} },
"serverInfo": {
"name": "ferroplan",
"version": env!("CARGO_PKG_VERSION"),
},
"instructions": "Author a PDDL domain + problem, then call `solve` \
(or `decompose` for a goal too big for one-shot search) and read \
the structured result. `validate` independently checks a plan.",
}))
}
"notifications/initialized" | "notifications/cancelled" => Outcome::Silent,
"ping" => Outcome::Reply(json!({})),
"tools/list" => Outcome::Reply(json!({ "tools": tool_specs() })),
"tools/call" => call_tool(params),
other => Outcome::Err(-32601, format!("method not found: {other}")),
}
}
fn tool_specs() -> Value {
json!([
{
"name": "solve",
"description": "Plan a PDDL domain + problem with ferroplan and return the \
structured Solution (typed steps, makespan/metric, statistics). Handles \
STRIPS, typing, ADL, numeric fluents, derived axioms, PDDL3 preferences, \
and PDDL2.1 temporal (durative actions) — mode is auto-detected. A \
solved:false result is a normal answer, not an error.",
"inputSchema": {
"type": "object",
"properties": {
"domain": { "type": "string", "description": "PDDL domain source" },
"problem": { "type": "string", "description": "PDDL problem source" },
"options": {
"type": "object",
"description": "Optional solver Options: mode (auto|ff|partition|pddl3|temporal), search (auto|ehc|best-first|ehc-then-best-first), weight_g, weight_h, threads, max_evaluated, optimize. Omitted fields use defaults."
}
},
"required": ["domain", "problem"]
}
},
{
"name": "parse",
"description": "Syntax-check a PDDL source string and return a structure \
summary WITHOUT grounding or solving — fast feedback while authoring. \
Auto-detects domain vs problem; reports ok/error (with a line number) \
plus name, requirements, and counts (types/predicates/actions, or \
objects/init/goal/metric). Use to catch PDDL mistakes before `solve`.",
"inputSchema": {
"type": "object",
"properties": {
"pddl": {
"type": "string",
"description": "A PDDL domain OR problem source string."
}
},
"required": ["pddl"]
}
},
{
"name": "validate",
"description": "Independently validate a plan against a domain + problem \
under ferroplan's own execution semantics (auto-detects classical vs \
temporal). Returns whether the plan is executable and goal-reaching, \
with a reason if not. Use to check a plan you wrote or one solve produced.",
"inputSchema": {
"type": "object",
"properties": {
"domain": { "type": "string", "description": "PDDL domain source" },
"problem": { "type": "string", "description": "PDDL problem source" },
"plan": {
"type": "string",
"description": "Plan to check: classical `step N: (action args)` lines, or a temporal `t: (action args) [dur]` plan."
}
},
"required": ["domain", "problem", "plan"]
}
},
{
"name": "decompose",
"description": "Decompose a temporal goal too big for one-shot search into \
ordered, individually-solved contracts, stitched into one validated plan. \
Returns the inspectable Decomposition: each contract's named sub-goal, \
sub-plan, and timeline offset, plus the stitched plan. A goal that can't \
be split falls back to a single monolithic contract (reported honestly).",
"inputSchema": {
"type": "object",
"properties": {
"domain": { "type": "string", "description": "PDDL domain source (durative actions)" },
"problem": { "type": "string", "description": "PDDL problem source" },
"options": { "type": "object", "description": "Optional solver Options (see `solve`)." }
},
"required": ["domain", "problem"]
}
}
])
}
fn call_tool(params: Value) -> Outcome {
let name = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
let result = match name {
"solve" => tool_solve(&args),
"parse" => tool_parse(&args),
"validate" => tool_validate(&args),
"decompose" => tool_decompose(&args),
other => return Outcome::Err(-32602, format!("unknown tool: {other}")),
};
Outcome::Reply(match result {
Ok(text) => json!({ "content": [text_block(&text)] }),
Err(text) => json!({ "content": [text_block(&text)], "isError": true }),
})
}
fn tool_solve(args: &Value) -> Result<String, String> {
let domain = require_str(args, "domain")?;
let problem = require_str(args, "problem")?;
let opts = parse_options(args)?;
let sol = ferroplan::solve(domain, problem, &opts).map_err(|e| e.to_string())?;
pretty(&sol)
}
fn tool_parse(args: &Value) -> Result<String, String> {
let pddl = require_str(args, "pddl")?;
pretty(&ferroplan::parse(pddl))
}
fn tool_validate(args: &Value) -> Result<String, String> {
let domain = require_str(args, "domain")?;
let problem = require_str(args, "problem")?;
let plan = require_str(args, "plan")?;
match ferroplan::plan::validate_plan(domain, problem, plan)? {
ferroplan::plan::Validity::Valid => Ok("Plan valid".to_string()),
ferroplan::plan::Validity::Invalid(why) => Ok(format!("Plan invalid: {why}")),
}
}
fn tool_decompose(args: &Value) -> Result<String, String> {
let domain = require_str(args, "domain")?;
let problem = require_str(args, "problem")?;
let opts = parse_options(args)?;
let dec = ferroplan::decompose(domain, problem, &opts).map_err(|e| e.to_string())?;
pretty(&dec)
}
fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
args.get(key)
.and_then(Value::as_str)
.ok_or_else(|| format!("missing required string argument `{key}`"))
}
fn parse_options(args: &Value) -> Result<ferroplan::Options, String> {
match args.get("options") {
None | Some(Value::Null) => Ok(ferroplan::Options::default()),
Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("invalid options: {e}")),
}
}
fn pretty<T: serde::Serialize>(v: &T) -> Result<String, String> {
serde_json::to_string_pretty(v).map_err(|e| e.to_string())
}
fn text_block(text: &str) -> Value {
json!({ "type": "text", "text": text })
}
fn error_obj(id: Value, code: i64, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
fn send(out: &mut impl Write, msg: &Value) {
if writeln!(out, "{msg}").is_ok() {
let _ = out.flush();
}
}