use crate::error::packop_error;
use grex_core::build_ls_tree;
use rmcp::{
handler::server::wrapper::Parameters,
model::{CallToolResult, Content},
ErrorData as McpError,
};
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema, Default)]
#[serde(deny_unknown_fields)]
pub struct LsParams {}
pub(crate) async fn handle(
state: &crate::ServerState,
Parameters(_p): Parameters<LsParams>,
) -> Result<CallToolResult, McpError> {
let workspace = (*state.workspace).clone();
let joined = tokio::task::spawn_blocking(move || build_ls_tree(&workspace)).await;
match joined {
Ok(Ok(tree)) => Ok(success_envelope(&tree)),
Ok(Err(detail)) => Ok(packop_error(&detail)),
Err(e) => Ok(packop_error(&format!("internal: blocking task failed: {e}"))),
}
}
fn success_envelope(tree: &grex_core::LsTree) -> CallToolResult {
let body = serde_json::to_string(tree)
.unwrap_or_else(|e| format!("{{\"error\":\"serialise LsTree: {e}\"}}"));
CallToolResult::success(vec![Content::text(body)])
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::handler::server::tool::schema_for_type;
use serde_json::Value;
use std::fs;
use tempfile::tempdir;
fn state_rooted_at(root: &std::path::Path) -> crate::ServerState {
crate::ServerState::new(
grex_core::Scheduler::new(1),
grex_core::Registry::default(),
root.join(".grex").join("events.jsonl"),
root.to_path_buf(),
)
}
#[test]
fn ls_params_schema_resolves() {
let _ = schema_for_type::<LsParams>();
}
#[test]
fn ls_params_rejects_workspace_override() {
let bad: Result<LsParams, _> =
serde_json::from_value(serde_json::json!({ "workspace": "/tmp" }));
assert!(bad.is_err(), "`workspace` must be rejected by the MCP schema");
}
#[tokio::test]
async fn ls_emits_workspace_envelope_with_synthetic_child() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n",
)
.unwrap();
fs::create_dir_all(root.join("alpha/.git")).unwrap();
let s = state_rooted_at(root);
let r = handle(&s, Parameters(LsParams::default())).await.unwrap();
assert_ne!(r.is_error, Some(true), "expected success envelope");
let text = r.content.first().unwrap().as_text().unwrap().text.clone();
let v: Value = serde_json::from_str(&text).unwrap();
assert!(v["workspace"].is_string(), "envelope must carry `workspace`");
let nodes = v["tree"].as_array().expect("tree array");
assert_eq!(nodes.len(), 1);
let root_node = &nodes[0];
assert_eq!(root_node["name"], Value::from("rootp"));
assert_eq!(root_node["type"], Value::from("meta"));
assert_eq!(root_node["synthetic"], Value::from(false));
let children = root_node["children"].as_array().expect("children array");
assert_eq!(children.len(), 1);
let child = &children[0];
assert_eq!(child["synthetic"], Value::from(true));
assert_eq!(child["type"], Value::from("scripted"));
}
#[tokio::test]
async fn ls_missing_manifest_returns_packop_error() {
let dir = tempdir().unwrap();
let s = state_rooted_at(dir.path());
let r = handle(&s, Parameters(LsParams::default())).await.unwrap();
assert_eq!(r.is_error, Some(true));
let text = r.content.first().unwrap().as_text().unwrap().text.clone();
let v: Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["data"]["kind"], Value::from("pack_op"));
}
}