umbriel-mcp 0.4.0

MCP server for the Umbriel Wayland compositor: windows, workspaces, outputs, pointer and keyboard input, screenshots with OCR, and the clipboard.
use serde_json::{Map, Value};

const BRIEF_WINDOW_FIELDS: &[&str] = &[
    "id",
    "app_id",
    "title",
    "output",
    "workspace",
    "visible",
    "floating",
    "fullscreen",
    "minimized",
    "x",
    "y",
    "w",
    "h",
];

const BRIEF_OUTPUT_FIELDS: &[&str] = &[
    "name",
    "description",
    "enabled",
    "focused",
    "position",
    "logical_size",
    "scale",
    "transform",
    "active_workspace",
];

fn keep_fields(entry: &Value, fields: &[&str]) -> Map<String, Value> {
    fields
        .iter()
        .filter_map(|field| Some(((*field).to_owned(), entry.get(*field)?.clone())))
        .collect()
}

pub fn brief_window(window: &Value, keyboard_focus_id: Option<&str>) -> Value {
    if !window.is_object() {
        return window.clone();
    }
    let mut brief = keep_fields(window, BRIEF_WINDOW_FIELDS);
    let has_keyboard_focus = keyboard_focus_id.is_some()
        && window.get("id").and_then(Value::as_str) == keyboard_focus_id;
    brief.insert(
        "has_keyboard_focus".to_owned(),
        Value::Bool(has_keyboard_focus),
    );
    Value::Object(brief)
}

fn current_mode(output: &Value) -> Option<String> {
    let mode = output
        .get("modes")?
        .as_array()?
        .iter()
        .find(|mode| mode.get("current").and_then(Value::as_bool) == Some(true))?;
    let width = mode.get("width")?.as_u64()?;
    let height = mode.get("height")?.as_u64()?;
    let hertz = mode.get("refresh_mhz")?.as_u64()? as f64 / 1000.0;
    Some(format!(
        "{width}x{height}@{}Hz",
        (hertz * 100.0).round() / 100.0
    ))
}

pub fn brief_output(output: &Value) -> Value {
    if !output.is_object() {
        return output.clone();
    }
    let mut brief = keep_fields(output, BRIEF_OUTPUT_FIELDS);
    if let Some(mode) = current_mode(output) {
        brief.insert("mode".to_owned(), Value::String(mode));
    }
    Value::Object(brief)
}

pub fn shown(entries: Value, detail: bool, brief: impl Fn(&Value) -> Value) -> Value {
    if detail {
        return entries;
    }
    match entries {
        Value::Array(items) => Value::Array(items.iter().map(&brief).collect()),
        single => brief(&single),
    }
}

#[cfg(test)]
mod tests {
    use super::{brief_output, brief_window};
    use serde_json::json;

    #[test]
    fn a_brief_window_keeps_only_what_identifies_and_places_it() {
        let window = json!({"id": "a", "title": "t", "x": 1, "pid": 9, "focused": true});
        assert_eq!(
            brief_window(&window, Some("a")),
            json!({"id": "a", "title": "t", "x": 1, "has_keyboard_focus": true})
        );
        assert_eq!(
            brief_window(&window, Some("b"))["has_keyboard_focus"],
            false
        );
        assert_eq!(brief_window(&window, None)["has_keyboard_focus"], false);
    }

    #[test]
    fn a_brief_output_names_its_current_mode() {
        let brief = brief_output(&json!({
            "name": "DP-1",
            "modes": [
                {"width": 2560, "height": 1440, "refresh_mhz": 143856, "current": true},
                {"width": 1920, "height": 1080, "refresh_mhz": 60000, "current": false}
            ]
        }));
        assert_eq!(brief, json!({"name": "DP-1", "mode": "2560x1440@143.86Hz"}));
    }

    #[test]
    fn no_focused_window_stays_as_umbriel_reported_it() {
        assert_eq!(brief_window(&json!(null), None), json!(null));
    }
}