umbriel-mcp 0.2.1

MCP server for the Umbriel Wayland compositor: windows, workspaces, outputs, pointer and keyboard input, screenshots with OCR, and the clipboard.
use crate::profile::Profile;
use anyhow::{Result, anyhow};
use clap::ValueEnum;
use serde_json::json;
use std::process::{Command, ExitCode};

const SERVER_NAME: &str = "umbriel";
const SERVER_BINARY: &str = "umbriel-mcp";
pub const SERVER_SUMMARY: &str = "Inspect and control this desktop's windows, workspaces, monitors, input, screen and clipboard (Umbriel Wayland compositor).";

#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Harness {
    Pi,
    ClaudeCode,
    Vscode,
    Opencode,
    Cursor,
    Zed,
}

#[derive(Debug, PartialEq, Eq)]
pub enum Registration {
    Delegate {
        program: &'static str,
        arguments: Vec<String>,
        follow_up: &'static str,
    },
    Snippet {
        file: &'static str,
        text: String,
        follow_up: &'static str,
    },
}

fn server_arguments(profile: Option<Profile>) -> Vec<String> {
    match profile {
        Some(profile) => vec!["--profile".to_owned(), profile.to_string()],
        None => Vec::new(),
    }
}

fn server_invocation(profile: Option<Profile>, server_command: &str) -> Vec<String> {
    std::iter::once(server_command.to_owned())
        .chain(server_arguments(profile))
        .collect()
}

fn pretty(value: serde_json::Value) -> String {
    serde_json::to_string_pretty(&value).expect("snippet is serializable")
}

pub fn plan(harness: Harness, profile: Option<Profile>, server_command: &str) -> Registration {
    let invocation = server_invocation(profile, server_command);
    let words = |prefix: &[&str]| -> Vec<String> {
        prefix
            .iter()
            .map(|word| (*word).to_owned())
            .chain(invocation.clone())
            .collect()
    };
    match harness {
        Harness::Pi => Registration::Delegate {
            program: "pi",
            arguments: words(&[
                "mcp",
                "add",
                SERVER_NAME,
                "--exposure",
                "direct",
                "--description",
                SERVER_SUMMARY,
                "--",
            ]),
            follow_up: "Start a new pi session; the tools are exposed directly to the model.",
        },
        Harness::ClaudeCode => Registration::Delegate {
            program: "claude",
            arguments: words(&["mcp", "add", "--scope", "user", SERVER_NAME, "--"]),
            follow_up: "Start a new Claude Code session and check `/mcp` lists umbriel.",
        },
        Harness::Vscode => Registration::Delegate {
            program: "code",
            arguments: vec![
                "--add-mcp".to_owned(),
                json!({
                    "name": SERVER_NAME,
                    "command": server_command,
                    "args": server_arguments(profile),
                })
                .to_string(),
            ],
            follow_up: "Reload the VS Code window and start the server from the MCP servers list.",
        },
        Harness::Opencode => Registration::Snippet {
            file: "opencode.json (project) or ~/.config/opencode/opencode.json",
            text: pretty(json!({
                "$schema": "https://opencode.ai/config.json",
                "mcp": { SERVER_NAME: {
                    "type": "local",
                    "command": invocation,
                    "enabled": true,
                }},
            })),
            follow_up: "Merge the \"umbriel\" entry into the file's \"mcp\" object, then restart opencode.",
        },
        Harness::Cursor => Registration::Snippet {
            file: "~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project)",
            text: pretty(json!({
                "mcpServers": { SERVER_NAME: {
                    "command": server_command,
                    "args": server_arguments(profile),
                }},
            })),
            follow_up: "Merge the \"umbriel\" entry into \"mcpServers\", then enable it under Cursor Settings > MCP.",
        },
        Harness::Zed => Registration::Snippet {
            file: "Zed settings.json (open it with the `zed: open settings` command)",
            text: pretty(json!({
                "context_servers": { SERVER_NAME: {
                    "command": server_command,
                    "args": server_arguments(profile),
                }},
            })),
            follow_up: "Merge the \"umbriel\" entry into \"context_servers\"; keep \"args\" even when empty or Zed rejects the whole file.",
        },
    }
}

fn is_on_path(program: &str) -> bool {
    crate::doctor::is_on_path(program)
}

fn server_command_for_registration() -> String {
    if is_on_path(SERVER_BINARY) {
        return SERVER_BINARY.to_owned();
    }
    std::env::current_exe()
        .ok()
        .and_then(|path| path.to_str().map(str::to_owned))
        .unwrap_or_else(|| SERVER_BINARY.to_owned())
}

fn manual_command(program: &str, arguments: &[String]) -> String {
    let quoted: Vec<String> = arguments
        .iter()
        .map(|argument| format!("'{}'", argument.replace('\'', "'\\''")))
        .collect();
    format!("{program} {}", quoted.join(" "))
}

fn execute(registration: Registration) -> Result<()> {
    match registration {
        Registration::Snippet {
            file,
            text,
            follow_up,
        } => {
            println!("Add this to {file}:\n\n{text}\n\n{follow_up}");
            println!("umbriel-mcp does not edit this file for you.");
            Ok(())
        }
        Registration::Delegate {
            program,
            arguments,
            follow_up,
        } => {
            if !is_on_path(program) {
                return Err(anyhow!(
                    "`{program}` was not found on PATH, so nothing was registered. \
                     Install it, or run this yourself later:\n  {}",
                    manual_command(program, &arguments)
                ));
            }
            let status = Command::new(program)
                .args(&arguments)
                .status()
                .map_err(|error| anyhow!("could not run `{program}`: {error}"))?;
            if !status.success() {
                return Err(anyhow!(
                    "`{program}` exited with {status}; registration was not completed"
                ));
            }
            println!("Registered umbriel with {program}.\n{follow_up}");
            Ok(())
        }
    }
}

pub fn run(harness: Harness, profile: Option<Profile>) -> ExitCode {
    let registration = plan(harness, profile, &server_command_for_registration());
    match execute(registration) {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("{error}");
            ExitCode::FAILURE
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Harness, Registration, SERVER_SUMMARY, plan};
    use crate::profile::Profile;
    use serde_json::{Value, json};

    fn delegated(harness: Harness, profile: Option<Profile>) -> (&'static str, Vec<String>) {
        match plan(harness, profile, "umbriel-mcp") {
            Registration::Delegate {
                program, arguments, ..
            } => (program, arguments),
            other => panic!("{harness:?} should delegate, got {other:?}"),
        }
    }

    fn snippet(harness: Harness, profile: Option<Profile>) -> (&'static str, Value) {
        match plan(harness, profile, "umbriel-mcp") {
            Registration::Snippet { file, text, .. } => (
                file,
                serde_json::from_str(&text).expect("snippet is valid JSON"),
            ),
            other => panic!("{harness:?} should print a snippet, got {other:?}"),
        }
    }

    fn strings(parts: &[&str]) -> Vec<String> {
        parts.iter().map(|part| (*part).to_owned()).collect()
    }

    #[test]
    fn pi_is_registered_with_direct_exposure_so_tools_reach_the_model() {
        let (program, arguments) = delegated(Harness::Pi, None);
        assert_eq!(program, "pi");
        assert_eq!(
            arguments,
            strings(&[
                "mcp",
                "add",
                "umbriel",
                "--exposure",
                "direct",
                "--description",
                SERVER_SUMMARY,
                "--",
                "umbriel-mcp"
            ])
        );
        assert!(SERVER_SUMMARY.contains("this desktop"));
    }

    #[test]
    fn claude_code_is_registered_for_the_user_across_projects() {
        let (program, arguments) = delegated(Harness::ClaudeCode, None);
        assert_eq!(program, "claude");
        assert_eq!(
            arguments,
            strings(&[
                "mcp",
                "add",
                "--scope",
                "user",
                "umbriel",
                "--",
                "umbriel-mcp"
            ])
        );
    }

    #[test]
    fn vscode_receives_the_server_definition_as_json() {
        let (program, arguments) = delegated(Harness::Vscode, None);
        assert_eq!(program, "code");
        assert_eq!(arguments[0], "--add-mcp");
        let definition: Value = serde_json::from_str(&arguments[1]).unwrap();
        assert_eq!(
            definition,
            json!({"name":"umbriel","command":"umbriel-mcp","args":[]})
        );
        assert_eq!(arguments.len(), 2);
    }

    #[test]
    fn a_profile_is_passed_through_only_when_requested() {
        let (_, without) = delegated(Harness::Pi, None);
        assert!(!without.contains(&"--profile".to_owned()));

        let (_, with) = delegated(Harness::Pi, Some(Profile::ReadOnly));
        assert_eq!(
            with[with.len() - 3..],
            strings(&["umbriel-mcp", "--profile", "read-only"])
        );

        let (_, claude) = delegated(Harness::ClaudeCode, Some(Profile::NoExec));
        assert_eq!(
            claude[claude.len() - 3..],
            strings(&["umbriel-mcp", "--profile", "no-exec"])
        );

        let (_, code) = delegated(Harness::Vscode, Some(Profile::NoExec));
        let definition: Value = serde_json::from_str(&code[1]).unwrap();
        assert_eq!(definition["args"], json!(["--profile", "no-exec"]));
    }

    #[test]
    fn an_explicit_full_profile_is_passed_through_too() {
        let (_, arguments) = delegated(Harness::Pi, Some(Profile::Full));
        assert_eq!(
            arguments[arguments.len() - 2..],
            strings(&["--profile", "full"])
        );
    }

    #[test]
    fn opencode_snippet_uses_its_local_server_format() {
        let (file, config) = snippet(Harness::Opencode, None);
        assert!(file.contains("opencode"), "{file}");
        assert_eq!(
            config["mcp"]["umbriel"],
            json!({"type":"local","command":["umbriel-mcp"],"enabled":true})
        );
    }

    #[test]
    fn opencode_command_is_one_array_including_the_profile_flag() {
        let (_, config) = snippet(Harness::Opencode, Some(Profile::NoExec));
        assert_eq!(
            config["mcp"]["umbriel"]["command"],
            json!(["umbriel-mcp", "--profile", "no-exec"])
        );
    }

    #[test]
    fn cursor_snippet_uses_mcp_servers() {
        let (file, config) = snippet(Harness::Cursor, None);
        assert!(file.contains(".cursor/mcp.json"), "{file}");
        assert_eq!(
            config["mcpServers"]["umbriel"],
            json!({"command":"umbriel-mcp","args":[]})
        );
    }

    #[test]
    fn zed_snippet_always_has_an_explicit_empty_argument_list() {
        let (file, config) = snippet(Harness::Zed, None);
        assert!(file.contains("zed"), "{file}");
        let server = &config["context_servers"]["umbriel"];
        assert_eq!(server["command"], json!("umbriel-mcp"));
        assert_eq!(server["args"], json!([]));
    }

    #[test]
    fn zed_snippet_carries_the_profile_in_args() {
        let (_, config) = snippet(Harness::Zed, Some(Profile::ReadOnly));
        assert_eq!(
            config["context_servers"]["umbriel"]["args"],
            json!(["--profile", "read-only"])
        );
    }

    #[test]
    fn a_custom_server_command_path_is_used_everywhere() {
        let Registration::Snippet { text, .. } =
            plan(Harness::Cursor, None, "/opt/tools/umbriel-mcp")
        else {
            panic!("cursor prints a snippet");
        };
        assert!(text.contains("/opt/tools/umbriel-mcp"));
    }

    #[test]
    fn every_registration_tells_the_user_what_to_do_next() {
        for harness in [
            Harness::Pi,
            Harness::ClaudeCode,
            Harness::Vscode,
            Harness::Opencode,
            Harness::Cursor,
            Harness::Zed,
        ] {
            let follow_up = match plan(harness, None, "umbriel-mcp") {
                Registration::Delegate { follow_up, .. }
                | Registration::Snippet { follow_up, .. } => follow_up,
            };
            assert!(follow_up.len() > 10, "{harness:?} needs a follow-up step");
        }
    }
}