umbriel-mcp 0.1.0

MCP server for the Umbriel Wayland compositor: windows, workspaces, outputs, pointer and keyboard input, screenshots with OCR, and the clipboard.
use anyhow::{anyhow, Result};
use std::io::Write;
use std::process::{Command, Stdio};

const PRESS_AND_RELEASE: u8 = 0xC0;
const PRESS_ONLY: u8 = 0x40;
const RELEASE_ONLY: u8 = 0x80;

fn run(program: &str, args: &[String]) -> Result<String> {
    let result = Command::new(program)
        .args(args)
        .output()
        .map_err(|cause| anyhow!("cannot run {program}: {cause}"))?;
    if !result.status.success() {
        return Err(anyhow!(
            "{program} failed: {}",
            String::from_utf8_lossy(&result.stderr).trim()
        ));
    }
    Ok(String::from_utf8_lossy(&result.stdout).into_owned())
}

fn button_base(name: &str) -> Result<u8> {
    match name {
        "left" => Ok(0x00),
        "right" => Ok(0x01),
        "middle" => Ok(0x02),
        "side" => Ok(0x03),
        other => Err(anyhow!(
            "unknown button {other}; expected left, right, middle or side"
        )),
    }
}

fn ydotool(args: &[String]) -> Result<()> {
    run("ydotool", args).map(|_| ())
}

pub fn move_pointer(x: i32, y: i32) -> Result<()> {
    ydotool(&[
        "mousemove".into(),
        "--absolute".into(),
        "-x".into(),
        x.to_string(),
        "-y".into(),
        y.to_string(),
    ])
}

pub fn click(button: &str, double: bool) -> Result<()> {
    let code = format!("0x{:02X}", button_base(button)? | PRESS_AND_RELEASE);
    let mut args = vec!["click".to_string()];
    if double {
        args.push("--repeat".into());
        args.push("2".into());
    }
    args.push(code);
    ydotool(&args)
}

pub fn scroll(direction: &str, amount: i32) -> Result<()> {
    let steps = match direction {
        "down" => amount,
        "up" => -amount,
        other => return Err(anyhow!("unknown direction {other}; expected up or down")),
    };
    ydotool(&[
        "mousemove".into(),
        "--wheel".into(),
        "-x".into(),
        "0".into(),
        "-y".into(),
        steps.to_string(),
    ])
}

pub fn drag(from: (i32, i32), to: (i32, i32), button: &str) -> Result<()> {
    let base = button_base(button)?;
    move_pointer(from.0, from.1)?;
    ydotool(&["click".into(), format!("0x{:02X}", base | PRESS_ONLY)])?;
    move_pointer(to.0, to.1)?;
    ydotool(&["click".into(), format!("0x{:02X}", base | RELEASE_ONLY)])
}

pub fn type_text(text: &str, delay_ms: u32) -> Result<()> {
    let mut args = Vec::new();
    if delay_ms > 0 {
        args.push("-d".to_string());
        args.push(delay_ms.to_string());
    }
    args.push("--".to_string());
    args.push(text.to_string());
    run("wtype", &args).map(|_| ())
}

pub fn press_keys(chord: &str) -> Result<()> {
    let mut parts: Vec<&str> = chord
        .split('+')
        .map(str::trim)
        .filter(|piece| !piece.is_empty())
        .collect();
    let key = parts
        .pop()
        .ok_or_else(|| anyhow!("empty key chord"))?
        .to_string();
    let modifiers: Vec<String> = parts.iter().map(|m| m.to_lowercase()).collect();

    let mut args = Vec::new();
    for modifier in &modifiers {
        args.push("-M".to_string());
        args.push(modifier.clone());
    }
    args.push("-k".to_string());
    args.push(key);
    for modifier in modifiers.iter().rev() {
        args.push("-m".to_string());
        args.push(modifier.clone());
    }
    run("wtype", &args).map(|_| ())
}

pub fn clipboard_read() -> Result<String> {
    run("wl-paste", &["--no-newline".to_string()])
}

pub fn clipboard_write(text: &str) -> Result<()> {
    let mut child = Command::new("wl-copy")
        .stdin(Stdio::piped())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .map_err(|cause| anyhow!("cannot run wl-copy: {cause}"))?;
    child
        .stdin
        .take()
        .ok_or_else(|| anyhow!("wl-copy stdin unavailable"))?
        .write_all(text.as_bytes())?;

    let status = child.wait()?;
    if !status.success() {
        return Err(anyhow!("wl-copy exited with {status}"));
    }
    Ok(())
}