use std::{
io::IsTerminal,
path::Path,
path::PathBuf,
sync::{Arc, OnceLock},
};
use anyhow::Result;
use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt,
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ErrorCode, Implementation, JsonObject,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
},
service::{MaybeSendFuture, RequestContext, RoleServer},
transport::stdio,
};
use serde_json::{Map, Value, json};
use crate::{
build_wait_params, client::Client, export_replay_file, resolve_socket, run_drop_command, run_replay_command,
target_params, with_window,
};
#[derive(Debug, Clone)]
pub(crate) struct HasgardMcpServer {
socket: Option<PathBuf>,
window: Option<String>,
resolved_socket: Arc<OnceLock<PathBuf>>,
}
pub(crate) async fn run_mcp_server(socket: Option<PathBuf>, window: Option<String>) -> Result<()> {
print_startup_banner(socket.as_deref(), window.as_deref());
let service = HasgardMcpServer::new(socket, window)
.serve(stdio())
.await
.map_err(|e| anyhow::anyhow!("failed to initialize MCP server: {e}"))?;
service.waiting().await.map_err(|e| anyhow::anyhow!("MCP server failed: {e}"))?;
Ok(())
}
fn print_startup_banner(socket: Option<&Path>, window: Option<&str>) {
if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() {
eprintln!("{}", startup_banner(socket, window));
}
}
fn startup_banner(socket: Option<&Path>, window: Option<&str>) -> String {
let socket = socket.map_or_else(|| "auto-detect on first tool call".to_owned(), |path| path.display().to_string());
let window = window.unwrap_or("default app window");
format!(
r"
tauri-hasgard MCP server
Status : listening on stdio
Socket : {socket}
Window : {window}
stdout is reserved for MCP JSON-RPC.
Configure your MCP client to launch this command instead of typing requests here.
"
)
}
impl HasgardMcpServer {
fn new(socket: Option<PathBuf>, window: Option<String>) -> Self {
Self { socket, window, resolved_socket: Arc::new(OnceLock::new()) }
}
async fn connect_client(&self) -> Result<Client> {
if let Some(socket) = &self.socket {
return Client::connect(socket).await;
}
if let Some(socket) = self.resolved_socket.get() {
return Client::connect(socket).await;
}
let socket = resolve_socket(None)?;
let client = Client::connect(&socket).await?;
let _ = self.resolved_socket.set(socket);
Ok(client)
}
async fn call_app(&self, method: &'static str, params: Option<Value>, window: Option<String>) -> Result<Value> {
let mut client = self.connect_client().await?;
client.call(method, with_window(params, window.as_deref())).await
}
async fn call_app_tool(
&self, method: &'static str, params: Option<Value>, window: Option<String>,
) -> Result<CallToolResult, McpError> {
Ok(match self.call_app(method, params, window).await {
Ok(result) => tool_success(result),
Err(err) => tool_error(err),
})
}
#[allow(clippy::too_many_lines)]
async fn call_tool_by_name(&self, name: &str, args: JsonObject) -> Result<CallToolResult, McpError> {
let name = normalize_tool_name(name);
if DANGEROUS_MCP_TOOLS.contains(&name) && !dangerous_mcp_tools_enabled() {
return Err(invalid_params(format!(
"tool '{name}' is disabled by default. Set {ENABLE_DANGEROUS_MCP_TOOLS_ENV}=1 to enable dangerous MCP tools."
)));
}
let window = self.window_arg(&args)?;
match name {
"ping" => self.call_app_tool("ping", None, window).await,
"windows" => self.call_app_tool("windows.list", None, None).await,
"state" => self.call_app_tool("state", None, window).await,
"snapshot" => {
self.call_app_tool(
"snapshot",
Some(json!({
"interactive": optional_bool(&args, "interactive")?.unwrap_or(false),
"selector": optional_string(&args, "selector")?,
"depth": optional_u8(&args, "depth")?,
})),
window,
)
.await
}
"diff" => {
let mut params = json!({
"interactive": optional_bool(&args, "interactive")?.unwrap_or(false),
"selector": optional_string(&args, "selector")?,
"depth": optional_u8(&args, "depth")?,
});
if let Some(reference) = args.get("reference") {
params["reference"] = reference.clone();
}
self.call_app_tool("diff", Some(params), window).await
}
"click" => self.target_call("click", &args, window).await,
"fill" => {
let mut params = target_params(&required_string(&args, "target")?);
params["value"] = json!(required_string(&args, "value")?);
self.call_app_tool("fill", Some(params), window).await
}
"type" => {
let mut params = target_params(&required_string(&args, "target")?);
params["text"] = json!(required_string(&args, "text")?);
self.call_app_tool("type", Some(params), window).await
}
"press" => self.call_app_tool("press", Some(json!({"key": required_string(&args, "key")?})), window).await,
"select" => {
let mut params = target_params(&required_string(&args, "target")?);
params["value"] = json!(required_string(&args, "value")?);
self.call_app_tool("select", Some(params), window).await
}
"check" => self.target_call("check", &args, window).await,
"scroll" => {
self.call_app_tool(
"scroll",
Some(json!({
"direction": optional_string(&args, "direction")?.unwrap_or_else(|| "down".to_owned()),
"amount": optional_i32(&args, "amount")?,
"ref": optional_ref(&args)?,
})),
window,
)
.await
}
"drag" => {
let source = required_string(&args, "source")?;
let mut params = json!({"source": target_params(&source)});
let target = optional_string(&args, "target")?;
let offset = args.get("offset").cloned();
match (target, offset) {
(Some(_), Some(_)) => {
return Err(invalid_params("drag accepts either 'target' or 'offset', not both"));
}
(None, None) => {
return Err(invalid_params("drag requires either 'target' or 'offset'"));
}
(Some(target), None) => {
params["target"] = target_params(&target);
}
(None, Some(offset)) => {
params["offset"] = offset;
}
}
self.call_app_tool("drag", Some(params), window).await
}
"drop" => self.call_drop_tool(args, window).await,
"text" => self.target_call("text", &args, window).await,
"html" => {
let params = optional_string(&args, "target")?.map(|target| target_params(&target));
self.call_app_tool("html", params, window).await
}
"value" => self.target_call("value", &args, window).await,
"attrs" => self.target_call("attrs", &args, window).await,
"eval" => {
self.call_app_tool("eval", Some(json!({"script": required_string(&args, "script")?})), window).await
}
"ipc" => {
self.call_app_tool(
"ipc",
Some(json!({
"command": required_string(&args, "command")?,
"args": args.get("args").cloned(),
})),
window,
)
.await
}
"screenshot" => {
self.call_app_tool("screenshot", Some(json!({"selector": optional_string(&args, "selector")?})), window)
.await
}
"screenshot_native" => {
let mut payload = json!({
"window_id": required_u32(&args, "window_id")?,
"output_path": required_string(&args, "output_path")?,
});
if let Some(format) = optional_string(&args, "format")? {
payload["format"] = json!(format);
}
self.call_app_tool("screenshot_native", Some(payload), window).await
}
"navigate" => {
let url = required_string(&args, "url")?;
validate_navigate_url(&url)?;
self.call_app_tool("navigate", Some(json!({ "url": url })), window).await
}
"url" => self.call_app_tool("url", None, window).await,
"title" => self.call_app_tool("title", None, window).await,
"wait" => {
let target = optional_string(&args, "target")?;
let selector = optional_string(&args, "selector")?;
let gone = optional_bool(&args, "gone")?.unwrap_or(false);
let timeout = optional_u64(&args, "timeout")?.unwrap_or(10_000);
let params = build_wait_params(target.as_deref(), selector.as_deref(), gone, timeout);
self.call_app_tool("wait", Some(params), window).await
}
"watch" => {
let mut watch_params = json!({
"selector": optional_string(&args, "selector")?,
"timeout": optional_u64(&args, "timeout")?.unwrap_or(10_000),
"stable": optional_u64(&args, "stable")?.unwrap_or(300),
});
if optional_bool(&args, "require_mutation")?.unwrap_or(false) {
watch_params["requireMutation"] = json!(true);
}
self.call_app_tool("watch", Some(watch_params), window).await
}
"logs" => self.call_logs_tool(&args, window).await,
"network" => self.call_network_tool(&args, window).await,
"storage_get" => {
self.call_app_tool(
"storage.get",
Some(json!({
"key": required_string(&args, "key")?,
"session": optional_bool(&args, "session")?.unwrap_or(false),
})),
window,
)
.await
}
"storage_set" => {
self.call_app_tool(
"storage.set",
Some(json!({
"key": required_string(&args, "key")?,
"value": required_string(&args, "value")?,
"session": optional_bool(&args, "session")?.unwrap_or(false),
})),
window,
)
.await
}
"storage_list" => {
self.call_app_tool(
"storage.list",
Some(json!({"session": optional_bool(&args, "session")?.unwrap_or(false)})),
window,
)
.await
}
"storage_clear" => {
self.call_app_tool(
"storage.clear",
Some(json!({"session": optional_bool(&args, "session")?.unwrap_or(false)})),
window,
)
.await
}
"forms" => {
let params = optional_string(&args, "selector")?.map(|selector| json!({ "selector": selector }));
self.call_app_tool("forms.dump", params, window).await
}
"assert_text" => self.assert_text(args, window, false).await,
"assert_contains" => self.assert_text(args, window, true).await,
"assert_visible" => self.assert_bool("visible", args, window, true).await,
"assert_hidden" => self.assert_bool("visible", args, window, false).await,
"assert_value" => self.assert_value(args, window).await,
"assert_count" => self.assert_count(args, window).await,
"assert_checked" => self.assert_bool("checked", args, window, true).await,
"assert_url" => self.assert_url(args, window).await,
"record_start" => self.call_app_tool("record.start", None, window).await,
"record_stop" => self.call_app_tool("record.stop", None, window).await,
"record_status" => self.call_app_tool("record.status", None, window).await,
"replay" => self.call_replay_tool(args, window).await,
_ => Err(McpError::new(ErrorCode::METHOD_NOT_FOUND, format!("unknown tool: {name}"), None)),
}
}
async fn target_call(
&self, method: &'static str, args: &JsonObject, window: Option<String>,
) -> Result<CallToolResult, McpError> {
let target = required_string(args, "target")?;
self.call_app_tool(method, Some(target_params(&target)), window).await
}
async fn call_logs_tool(&self, args: &JsonObject, window: Option<String>) -> Result<CallToolResult, McpError> {
if optional_bool(args, "clear")?.unwrap_or(false) {
return self.call_app_tool("console.clear", None, window).await;
}
let mut params = Map::new();
insert_optional_string(&mut params, args, "level")?;
insert_optional_usize(&mut params, args, "last")?;
self.call_app_tool("console.getLogs", Some(Value::Object(params)), window).await
}
async fn call_network_tool(&self, args: &JsonObject, window: Option<String>) -> Result<CallToolResult, McpError> {
if optional_bool(args, "clear")?.unwrap_or(false) {
return self.call_app_tool("network.clear", None, window).await;
}
let mut params = Map::new();
insert_optional_string(&mut params, args, "filter")?;
insert_optional_usize(&mut params, args, "last")?;
if optional_bool(args, "failed")?.unwrap_or(false) {
params.insert("failedOnly".into(), json!(true));
}
self.call_app_tool("network.getRequests", Some(Value::Object(params)), window).await
}
async fn call_drop_tool(&self, args: JsonObject, window: Option<String>) -> Result<CallToolResult, McpError> {
let target = required_string(&args, "target")?;
let files: Vec<PathBuf> = required_string_array(&args, "files")?.into_iter().map(PathBuf::from).collect();
if files.is_empty() {
return Err(invalid_params("'files' must contain at least one path"));
}
let mut client = match self.connect_client().await {
Ok(client) => client,
Err(err) => return Ok(tool_error(err)),
};
Ok(match run_drop_command(&mut client, &target, files, window.as_deref()).await {
Ok(result) => tool_success(result),
Err(err) => tool_error(err),
})
}
async fn call_replay_tool(&self, args: JsonObject, window: Option<String>) -> Result<CallToolResult, McpError> {
let path = PathBuf::from(required_string(&args, "path")?);
let export = optional_string(&args, "export")?;
if let Some(export) = export.as_deref() {
return Ok(match export_replay_file(&path, export) {
Ok(result) => tool_success(result),
Err(err) => tool_error(err),
});
}
let mut client = match self.connect_client().await {
Ok(client) => client,
Err(err) => return Ok(tool_error(err)),
};
Ok(match run_replay_command(&mut client, &path, None, window.as_deref()).await {
Ok(result) => tool_success(result),
Err(err) => tool_error(err),
})
}
async fn assert_text(
&self, args: JsonObject, window: Option<String>, contains: bool,
) -> Result<CallToolResult, McpError> {
let expected = required_string(&args, "expected")?;
let target = required_string(&args, "target")?;
let actual = match self.call_app("text", Some(target_params(&target)), window).await {
Ok(Value::String(actual)) => actual,
Ok(other) => {
return Ok(tool_error_msg(format!("expected string response, got {other}")));
}
Err(err) => return Ok(tool_error(err)),
};
let passed = if contains { actual.contains(&expected) } else { actual == expected };
if passed {
Ok(tool_success(json!({"ok": true})))
} else {
let message = if contains {
format!("text does not contain \"{expected}\", got \"{actual}\"")
} else {
format!("expected text \"{expected}\", got \"{actual}\"")
};
Ok(tool_error_msg(message))
}
}
async fn assert_value(&self, args: JsonObject, window: Option<String>) -> Result<CallToolResult, McpError> {
let expected = required_string(&args, "expected")?;
let target = required_string(&args, "target")?;
let actual = match self.call_app("value", Some(target_params(&target)), window).await {
Ok(Value::String(actual)) => actual,
Ok(other) => {
return Ok(tool_error_msg(format!("expected string response, got {other}")));
}
Err(err) => return Ok(tool_error(err)),
};
if actual == expected {
Ok(tool_success(json!({"ok": true})))
} else {
Ok(tool_error_msg(format!("expected value \"{expected}\", got \"{actual}\"")))
}
}
async fn assert_bool(
&self, method: &'static str, args: JsonObject, window: Option<String>, expected: bool,
) -> Result<CallToolResult, McpError> {
let target = required_string(&args, "target")?;
let field = method;
let actual = match self.call_app(method, Some(target_params(&target)), window).await {
Ok(result) => match result.get(field).and_then(Value::as_bool) {
Some(value) => value,
None => return Ok(tool_error_msg(format!("missing boolean field '{field}'"))),
},
Err(err) => return Ok(tool_error(err)),
};
if actual == expected {
Ok(tool_success(json!({"ok": true})))
} else if method == "visible" && expected {
Ok(tool_error_msg("element is not visible"))
} else if method == "visible" {
Ok(tool_error_msg("element is visible"))
} else if method == "checked" && expected {
Ok(tool_error_msg("element is not checked"))
} else if method == "checked" {
Ok(tool_error_msg("element is checked"))
} else {
Ok(tool_error_msg(format!("element '{method}' state mismatch: expected {expected}")))
}
}
async fn assert_count(&self, args: JsonObject, window: Option<String>) -> Result<CallToolResult, McpError> {
let selector = required_string(&args, "selector")?;
let expected = required_u64(&args, "expected")?;
let actual = match self.call_app("count", Some(json!({"selector": selector})), window).await {
Ok(result) => match result.get("count").and_then(Value::as_u64) {
Some(value) => value,
None => return Ok(tool_error_msg("missing 'count' field")),
},
Err(err) => return Ok(tool_error(err)),
};
if actual == expected {
Ok(tool_success(json!({"ok": true})))
} else {
Ok(tool_error_msg(format!("expected {expected} elements, found {actual}")))
}
}
async fn assert_url(&self, args: JsonObject, window: Option<String>) -> Result<CallToolResult, McpError> {
let expected = required_string(&args, "expected")?;
let actual = match self.call_app("url", None, window).await {
Ok(Value::String(actual)) => actual,
Ok(other) => {
return Ok(tool_error_msg(format!("expected string response, got {other}")));
}
Err(err) => return Ok(tool_error(err)),
};
if actual.contains(&expected) {
Ok(tool_success(json!({"ok": true})))
} else {
Ok(tool_error_msg(format!("URL does not contain \"{expected}\", got \"{actual}\"")))
}
}
fn window_arg(&self, args: &JsonObject) -> Result<Option<String>, McpError> {
optional_string(args, "window").map(|window| window.or_else(|| self.window.clone()))
}
}
impl ServerHandler for HasgardMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(
Implementation::new("tauri-hasgard", env!("CARGO_PKG_VERSION"))
.with_title("tauri-hasgard")
.with_description("MCP server for testing Tauri apps through tauri-hasgard"),
)
.with_instructions("Use these tools to inspect and control a running Tauri app through tauri-hasgard.")
}
fn list_tools(
&self, _request: Option<PaginatedRequestParams>, _context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, McpError>> + MaybeSendFuture + '_ {
std::future::ready(Ok(ListToolsResult::with_all_items(tools())))
}
fn call_tool(
&self, request: CallToolRequestParams, _context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, McpError>> + MaybeSendFuture + '_ {
let name = request.name.to_string();
let args = request.arguments.unwrap_or_default();
async move { self.call_tool_by_name(&name, args).await.map(Into::into) }
}
fn get_tool(&self, name: &str) -> Option<Tool> {
let name = namespaced_tool_name(name);
cached_tools().iter().find(|tool| tool.name == name.as_str()).cloned()
}
}
const HASGARD_PREFIX: &str = "hasgard.";
const ENABLE_DANGEROUS_MCP_TOOLS_ENV: &str = "TAURI_HASGARD_MCP_ENABLE_DANGEROUS_TOOLS";
const DANGEROUS_MCP_TOOLS: &[&str] = &["drop", "eval", "ipc"];
fn normalize_tool_name(name: &str) -> &str {
name.strip_prefix(HASGARD_PREFIX).unwrap_or(name)
}
fn namespaced_tool_name(name: &str) -> String {
if name.starts_with(HASGARD_PREFIX) { name.to_owned() } else { format!("{HASGARD_PREFIX}{name}") }
}
fn dangerous_mcp_tools_enabled() -> bool {
std::env::var(ENABLE_DANGEROUS_MCP_TOOLS_ENV)
.ok()
.is_some_and(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
}
fn validate_navigate_url(url: &str) -> Result<(), McpError> {
let stripped: String = url.chars().filter(|c| !matches!(c, '\t' | '\n' | '\r')).collect();
if stripped.trim_start_matches(|c| c <= '\u{20}').to_ascii_lowercase().starts_with("javascript:") {
return Err(invalid_params("navigate does not allow javascript: URLs for security reasons"));
}
Ok(())
}
struct ToolSpec {
name: &'static str,
description: &'static str,
schema: fn() -> Arc<JsonObject>,
read_only: bool,
destructive: bool,
idempotent: bool,
}
#[allow(clippy::too_many_lines)]
fn tool_specs() -> Vec<ToolSpec> {
vec![
ToolSpec {
name: "attrs",
description: "Get all HTML attributes for an element target.",
schema: target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "check",
description: "Toggle a checkbox or radio element.",
schema: target_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "click",
description: "Click an element target by ref, selector, or coordinates.",
schema: target_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "diff",
description: "Compare the current webview to the previous or supplied snapshot.",
schema: diff_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "drag",
description: "Drag an element to another target or by an offset.",
schema: drag_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "drop",
description: "Drop one or more local files on an element target.",
schema: drop_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "eval",
description: "Evaluate JavaScript in the WebView context.",
schema: eval_schema,
read_only: false,
destructive: true,
idempotent: false,
},
ToolSpec {
name: "fill",
description: "Clear and fill an input target with a value.",
schema: fill_schema,
read_only: false,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "forms",
description: "Dump all form fields in the webview document or inside a selector.",
schema: selector_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "html",
description: "Get inner HTML for an element target, or the full webview document if target is omitted.",
schema: optional_target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "ipc",
description: "Invoke a Tauri IPC command with optional JSON arguments.",
schema: ipc_schema,
read_only: false,
destructive: true,
idempotent: false,
},
ToolSpec {
name: "logs",
description: "Read or clear captured console logs.",
schema: logs_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "navigate",
description: "Navigate the WebView to a URL.",
schema: navigate_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "network",
description: "Read or clear captured network requests.",
schema: network_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "ping",
description: "Check connectivity with the running Tauri app.",
schema: empty_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "press",
description: "Press a keyboard key.",
schema: press_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "record_start",
description: "Start recording app interactions.",
schema: empty_schema,
read_only: false,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "record_status",
description: "Get recorder status.",
schema: empty_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "record_stop",
description: "Stop recording and return recorded entries.",
schema: empty_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "replay",
description: "Replay or export a recorded tauri-hasgard session file.",
schema: replay_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "screenshot",
description: "Capture the full webview document or an element selector as a PNG data URL.",
schema: selector_schema,
read_only: true,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "screenshot_native",
description: "Capture a native window by `window_id` and write a PNG to `output_path`. Returns path + metadata; never inlines bytes.",
schema: hasgard_screenshot_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "scroll",
description: "Scroll the webview document or an element ref.",
schema: scroll_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "select",
description: "Select an option in a select element.",
schema: fill_schema,
read_only: false,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "snapshot",
description: "Capture an accessibility snapshot of the WebView.",
schema: snapshot_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "state",
description: "Get webview URL, title, viewport, and scroll state.",
schema: empty_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "storage_clear",
description: "Clear localStorage or sessionStorage.",
schema: session_schema,
read_only: false,
destructive: true,
idempotent: true,
},
ToolSpec {
name: "storage_get",
description: "Read a localStorage or sessionStorage key.",
schema: storage_get_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "storage_list",
description: "List localStorage or sessionStorage entries.",
schema: session_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "storage_set",
description: "Set a localStorage or sessionStorage key.",
schema: storage_set_schema,
read_only: false,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "text",
description: "Get text content for an element target.",
schema: target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "title",
description: "Get the current webview title.",
schema: empty_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "type",
description: "Type text into an element target without clearing it first.",
schema: type_schema,
read_only: false,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "url",
description: "Get the current webview URL.",
schema: empty_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "value",
description: "Get an input, textarea, or select value.",
schema: target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "wait",
description: "Wait for an element or condition.",
schema: wait_schema,
read_only: true,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "watch",
description: "Watch for DOM mutations until the webview document is stable.",
schema: watch_schema,
read_only: true,
destructive: false,
idempotent: false,
},
ToolSpec {
name: "windows",
description: "List all open Tauri windows.",
schema: global_empty_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_checked",
description: "Assert that a checkbox or radio target is checked.",
schema: target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_contains",
description: "Assert that target text contains a substring.",
schema: expected_target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_count",
description: "Assert the number of elements matching a selector.",
schema: assert_count_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_hidden",
description: "Assert that an element target is hidden.",
schema: target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_text",
description: "Assert exact text content for an element target.",
schema: expected_target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_url",
description: "Assert that the current URL contains a substring.",
schema: expected_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_value",
description: "Assert an input, textarea, or select value.",
schema: expected_target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
ToolSpec {
name: "assert_visible",
description: "Assert that an element target is visible.",
schema: target_schema,
read_only: true,
destructive: false,
idempotent: true,
},
]
}
fn tools() -> Vec<Tool> {
cached_tools().clone()
}
fn cached_tools() -> &'static Vec<Tool> {
static TOOLS: OnceLock<Vec<Tool>> = OnceLock::new();
TOOLS.get_or_init(build_tools)
}
fn build_tools() -> Vec<Tool> {
build_tools_with_flag(dangerous_mcp_tools_enabled())
}
fn build_tools_with_flag(enable_dangerous_tools: bool) -> Vec<Tool> {
let mut specs = tool_specs();
if !enable_dangerous_tools {
specs.retain(|spec| !DANGEROUS_MCP_TOOLS.contains(&spec.name));
}
specs.sort_by_key(|spec| spec.name);
specs
.into_iter()
.map(|spec| {
Tool::new(namespaced_tool_name(spec.name), spec.description, (spec.schema)()).with_annotations(
ToolAnnotations::new()
.read_only(spec.read_only)
.destructive(spec.destructive)
.idempotent(spec.idempotent)
.open_world(false),
)
})
.collect()
}
fn tool_success(result: Value) -> CallToolResult {
let mut payload = Map::new();
payload.insert("result".to_owned(), result);
CallToolResult::structured(Value::Object(payload))
}
fn tool_error(err: impl std::fmt::Display) -> CallToolResult {
tool_error_msg(err.to_string())
}
fn tool_error_msg(message: impl Into<String>) -> CallToolResult {
CallToolResult::structured_error(json!({ "error": message.into() }))
}
fn invalid_params(message: impl Into<String>) -> McpError {
McpError::invalid_params(message.into(), None)
}
fn required_string(args: &JsonObject, name: &str) -> Result<String, McpError> {
args.get(name)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| invalid_params(format!("'{name}' is required and must be a string")))
}
fn optional_string(args: &JsonObject, name: &str) -> Result<Option<String>, McpError> {
match args.get(name) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
_ => Err(invalid_params(format!("'{name}' must be a string"))),
}
}
fn required_u64(args: &JsonObject, name: &str) -> Result<u64, McpError> {
args.get(name)
.and_then(Value::as_u64)
.ok_or_else(|| invalid_params(format!("'{name}' is required and must be an integer")))
}
fn required_u32(args: &JsonObject, name: &str) -> Result<u32, McpError> {
let value = required_u64(args, name)?;
u32::try_from(value).map_err(|_| invalid_params(format!("'{name}' is out of range for u32")))
}
fn optional_u64(args: &JsonObject, name: &str) -> Result<Option<u64>, McpError> {
match args.get(name) {
None | Some(Value::Null) => Ok(None),
Some(value) => value.as_u64().map(Some).ok_or_else(|| invalid_params(format!("'{name}' must be an integer"))),
}
}
fn optional_usize(args: &JsonObject, name: &str) -> Result<Option<usize>, McpError> {
optional_u64(args, name)?
.map(|value| usize::try_from(value).map_err(|_| invalid_params(format!("'{name}' is out of range for usize"))))
.transpose()
}
fn optional_i32(args: &JsonObject, name: &str) -> Result<Option<i32>, McpError> {
match args.get(name) {
None | Some(Value::Null) => Ok(None),
Some(value) => {
let parsed = value.as_i64().ok_or_else(|| invalid_params(format!("'{name}' must be an integer")))?;
i32::try_from(parsed).map(Some).map_err(|_| invalid_params(format!("'{name}' is out of range for i32")))
}
}
}
fn optional_u8(args: &JsonObject, name: &str) -> Result<Option<u8>, McpError> {
match optional_u64(args, name)? {
Some(value) => {
u8::try_from(value).map(Some).map_err(|_| invalid_params(format!("'{name}' is out of range for u8")))
}
None => Ok(None),
}
}
fn optional_bool(args: &JsonObject, name: &str) -> Result<Option<bool>, McpError> {
match args.get(name) {
None | Some(Value::Null) => Ok(None),
Some(value) => value.as_bool().map(Some).ok_or_else(|| invalid_params(format!("'{name}' must be a boolean"))),
}
}
fn optional_ref(args: &JsonObject) -> Result<Option<String>, McpError> {
match optional_string(args, "ref")? {
Some(value) => Ok(Some(value.trim_start_matches('@').to_owned())),
None => Ok(None),
}
}
fn required_string_array(args: &JsonObject, name: &str) -> Result<Vec<String>, McpError> {
let values = args
.get(name)
.and_then(Value::as_array)
.ok_or_else(|| invalid_params(format!("'{name}' is required and must be an array")))?;
values
.iter()
.map(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| invalid_params(format!("'{name}' must contain only strings")))
})
.collect()
}
fn insert_optional_string(params: &mut Map<String, Value>, args: &JsonObject, name: &str) -> Result<(), McpError> {
if let Some(value) = optional_string(args, name)? {
params.insert(name.to_owned(), json!(value));
}
Ok(())
}
fn insert_optional_usize(params: &mut Map<String, Value>, args: &JsonObject, name: &str) -> Result<(), McpError> {
if let Some(value) = optional_usize(args, name)? {
params.insert(name.to_owned(), json!(value));
}
Ok(())
}
fn empty_schema() -> Arc<JsonObject> {
object_schema(Map::new(), &[])
}
fn global_empty_schema() -> Arc<JsonObject> {
let mut schema = Map::new();
schema.insert("type".to_owned(), json!("object"));
schema.insert("properties".to_owned(), Value::Object(Map::new()));
schema.insert("additionalProperties".to_owned(), json!(false));
Arc::new(schema)
}
fn target_schema() -> Arc<JsonObject> {
object_schema(props([("target", string_prop("Element ref, CSS selector, or x,y coordinates."))]), &["target"])
}
fn optional_target_schema() -> Arc<JsonObject> {
object_schema(props([("target", string_prop("Optional element ref, CSS selector, or x,y coordinates."))]), &[])
}
fn expected_schema() -> Arc<JsonObject> {
object_schema(props([("expected", string_prop("Expected value or substring."))]), &["expected"])
}
fn expected_target_schema() -> Arc<JsonObject> {
object_schema(
props([
("target", string_prop("Element ref, CSS selector, or x,y coordinates.")),
("expected", string_prop("Expected value or substring.")),
]),
&["target", "expected"],
)
}
fn snapshot_schema() -> Arc<JsonObject> {
object_schema(
props([
("interactive", bool_prop("Only include interactive elements.")),
("selector", string_prop("CSS selector to scope the snapshot.")),
("depth", integer_prop("Maximum traversal depth.")),
]),
&[],
)
}
fn diff_schema() -> Arc<JsonObject> {
object_schema(
props([
("interactive", bool_prop("Only include interactive elements.")),
("selector", string_prop("CSS selector to scope the new snapshot.")),
("depth", integer_prop("Maximum traversal depth.")),
("reference", any_prop("Optional prior snapshot object to compare against.")),
]),
&[],
)
}
fn fill_schema() -> Arc<JsonObject> {
object_schema(
props([
("target", string_prop("Element ref, CSS selector, or x,y coordinates.")),
("value", string_prop("Value to set.")),
]),
&["target", "value"],
)
}
fn type_schema() -> Arc<JsonObject> {
object_schema(
props([
("target", string_prop("Element ref, CSS selector, or x,y coordinates.")),
("text", string_prop("Text to type.")),
]),
&["target", "text"],
)
}
fn press_schema() -> Arc<JsonObject> {
object_schema(props([("key", string_prop("Keyboard key to press."))]), &["key"])
}
fn scroll_schema() -> Arc<JsonObject> {
object_schema(
props([
("direction", enum_prop("Direction to scroll.", &["up", "down", "left", "right", "top", "bottom"])),
("amount", integer_prop("Pixel amount to scroll.")),
("ref", string_prop("Optional element ref, with or without @.")),
]),
&[],
)
}
fn drag_schema() -> Arc<JsonObject> {
object_schema(
props([
("source", string_prop("Source element ref, selector, or coordinates.")),
("target", string_prop("Destination element ref, selector, or coordinates.")),
("offset", any_prop("Optional offset object such as {\"x\": 0, \"y\": 100}.")),
]),
&["source"],
)
}
fn drop_schema() -> Arc<JsonObject> {
object_schema(
props([
("target", string_prop("Drop target ref, selector, or coordinates.")),
("files", array_string_prop("Local file paths to drop.")),
]),
&["target", "files"],
)
}
fn eval_schema() -> Arc<JsonObject> {
object_schema(props([("script", string_prop("JavaScript to evaluate."))]), &["script"])
}
fn ipc_schema() -> Arc<JsonObject> {
object_schema(
props([
("command", string_prop("Tauri IPC command name.")),
("args", any_prop("Optional JSON object of command arguments.")),
]),
&["command"],
)
}
fn hasgard_screenshot_schema() -> Arc<JsonObject> {
object_schema(
props([
(
"window_id",
json!({
"type": "integer",
"minimum": 0,
"maximum": u32::MAX,
"description": "Native window id (e.g. `CGWindowID` on macOS) to capture. Must fit in u32.",
}),
),
(
"output_path",
string_prop(
"Absolute path where the PNG is written. The parent directory must already exist; the file is written atomically via a temp + rename.",
),
),
("format", enum_prop("Image format. v1 accepts only \"png\".", &["png"])),
]),
&["window_id", "output_path"],
)
}
fn selector_schema() -> Arc<JsonObject> {
object_schema(props([("selector", string_prop("Optional CSS selector."))]), &[])
}
fn navigate_schema() -> Arc<JsonObject> {
object_schema(props([("url", string_prop("URL to navigate to."))]), &["url"])
}
fn wait_schema() -> Arc<JsonObject> {
object_schema(
props([
("target", string_prop("Element ref, CSS selector, or x,y coordinates.")),
("selector", string_prop("CSS selector to wait for.")),
("gone", bool_prop("Wait for the element to disappear.")),
("timeout", integer_prop("Timeout in milliseconds.")),
]),
&[],
)
}
fn watch_schema() -> Arc<JsonObject> {
object_schema(
props([
("selector", string_prop("CSS selector to scope observation.")),
("timeout", integer_prop("Timeout in milliseconds.")),
("stable", integer_prop("Stability window in milliseconds.")),
(
"require_mutation",
bool_prop(
"Defer the stability timer until at least one DOM mutation occurs. \
Rejects on timeout when nothing changed.",
),
),
]),
&[],
)
}
fn logs_schema() -> Arc<JsonObject> {
object_schema(
props([
("level", enum_prop("Optional log level filter.", &["log", "info", "warn", "error"])),
("last", integer_prop("Return only the last N log entries.")),
("clear", bool_prop("Clear the log buffer instead of reading it.")),
]),
&[],
)
}
fn network_schema() -> Arc<JsonObject> {
object_schema(
props([
("filter", string_prop("Optional URL substring filter.")),
("failed", bool_prop("Only return failed requests.")),
("last", integer_prop("Return only the last N requests.")),
("clear", bool_prop("Clear the request buffer instead of reading it.")),
]),
&[],
)
}
fn session_schema() -> Arc<JsonObject> {
object_schema(props([("session", bool_prop("Use sessionStorage instead of localStorage."))]), &[])
}
fn storage_get_schema() -> Arc<JsonObject> {
object_schema(
props([
("key", string_prop("Storage key.")),
("session", bool_prop("Use sessionStorage instead of localStorage.")),
]),
&["key"],
)
}
fn storage_set_schema() -> Arc<JsonObject> {
object_schema(
props([
("key", string_prop("Storage key.")),
("value", string_prop("Storage value.")),
("session", bool_prop("Use sessionStorage instead of localStorage.")),
]),
&["key", "value"],
)
}
fn assert_count_schema() -> Arc<JsonObject> {
object_schema(
props([
("selector", string_prop("CSS selector to count.")),
("expected", integer_prop("Expected element count.")),
]),
&["selector", "expected"],
)
}
fn replay_schema() -> Arc<JsonObject> {
object_schema(
props([
("path", string_prop("Path to a recording JSON file.")),
("export", enum_prop("Export format instead of replaying.", &["sh"])),
]),
&["path"],
)
}
fn object_schema(mut properties: Map<String, Value>, required: &[&str]) -> Arc<JsonObject> {
properties
.insert("window".to_owned(), string_prop("Optional Tauri window label overriding the MCP server default."));
let mut schema = Map::new();
schema.insert("type".to_owned(), json!("object"));
schema.insert("properties".to_owned(), Value::Object(properties));
if !required.is_empty() {
schema.insert("required".to_owned(), json!(required));
}
schema.insert("additionalProperties".to_owned(), json!(false));
Arc::new(schema)
}
fn props<const N: usize>(properties: [(&str, Value); N]) -> Map<String, Value> {
properties.into_iter().map(|(name, schema)| (name.to_owned(), schema)).collect()
}
fn string_prop(description: &str) -> Value {
json!({"type": "string", "description": description})
}
fn bool_prop(description: &str) -> Value {
json!({"type": "boolean", "description": description})
}
fn integer_prop(description: &str) -> Value {
json!({"type": "integer", "description": description})
}
fn array_string_prop(description: &str) -> Value {
json!({"type": "array", "items": {"type": "string"}, "description": description})
}
fn any_prop(description: &str) -> Value {
json!({"description": description})
}
fn enum_prop(description: &str, values: &[&str]) -> Value {
json!({"type": "string", "enum": values, "description": description})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use crate::protocol::{Request, Response};
#[cfg(unix)]
use serial_test::serial;
#[cfg(unix)]
use tokio::net::UnixListener;
#[cfg(unix)]
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
task::JoinHandle,
};
#[test]
fn tool_list_matches_cli_command_surface() {
let tools = build_tools_with_flag(true);
assert!(tools.iter().all(|tool| tool.name.as_ref().starts_with(HASGARD_PREFIX)));
let names: Vec<&str> = tools.iter().map(|tool| normalize_tool_name(tool.name.as_ref())).collect();
let expected = vec![
"assert_checked",
"assert_contains",
"assert_count",
"assert_hidden",
"assert_text",
"assert_url",
"assert_value",
"assert_visible",
"attrs",
"check",
"click",
"diff",
"drag",
"drop",
"eval",
"fill",
"forms",
"html",
"ipc",
"logs",
"navigate",
"network",
"ping",
"press",
"record_start",
"record_status",
"record_stop",
"replay",
"screenshot",
"screenshot_native",
"scroll",
"select",
"snapshot",
"state",
"storage_clear",
"storage_get",
"storage_list",
"storage_set",
"text",
"title",
"type",
"url",
"value",
"wait",
"watch",
"windows",
];
assert_eq!(names, expected);
}
#[test]
fn tool_name_helpers_are_round_trip_symmetric() {
assert_eq!(normalize_tool_name("click"), "click");
assert_eq!(normalize_tool_name("hasgard.click"), "click");
assert_eq!(namespaced_tool_name("click"), "hasgard.click");
assert_eq!(namespaced_tool_name("hasgard.click"), "hasgard.click");
}
#[test]
fn tool_name_helpers_handle_corner_cases() {
assert_eq!(normalize_tool_name(""), "");
assert_eq!(normalize_tool_name(HASGARD_PREFIX), "");
assert_eq!(normalize_tool_name("hasgard.hasgard.click"), "hasgard.click");
assert_eq!(namespaced_tool_name(""), HASGARD_PREFIX);
assert_eq!(namespaced_tool_name(HASGARD_PREFIX), HASGARD_PREFIX);
assert_eq!(namespaced_tool_name("hasgard.click"), "hasgard.click");
}
#[test]
fn get_tool_resolves_bare_and_prefixed_names_to_same_tool() {
let hasgard = HasgardMcpServer::new(None, None);
let bare = hasgard.get_tool("click").expect("bare name resolves");
let prefixed = hasgard.get_tool("hasgard.click").expect("prefixed name resolves");
assert_eq!(bare.name, prefixed.name);
assert_eq!(bare.description, prefixed.description);
assert_eq!(bare.name.as_ref(), "hasgard.click");
}
#[test]
fn schemas_include_window_override() {
let schema = target_schema();
let properties = schema.get("properties").and_then(Value::as_object).expect("schema has properties");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("window"));
}
#[test]
fn scroll_schema_accepts_top_and_bottom_directions() {
let schema = scroll_schema();
let direction = schema
.get("properties")
.and_then(Value::as_object)
.and_then(|p| p.get("direction"))
.and_then(Value::as_object)
.expect("scroll schema has direction property");
let values: Vec<&str> = direction
.get("enum")
.and_then(Value::as_array)
.expect("direction has enum")
.iter()
.filter_map(Value::as_str)
.collect();
assert_eq!(values.len(), 6, "scroll direction enum must have exactly 6 string variants, got {values:?}");
for expected in ["up", "down", "left", "right", "top", "bottom"] {
assert!(values.contains(&expected), "scroll direction enum must include {expected}");
}
}
#[test]
fn hasgard_screenshot_tool_advertises_path_only_contract() {
let tool = cached_tools()
.iter()
.find(|t| t.name == "hasgard.screenshot_native")
.expect("hasgard.screenshot_native tool registered");
let props = tool.input_schema.get("properties").and_then(Value::as_object).expect("schema has properties");
for required in ["window_id", "output_path", "format"] {
assert!(
props.contains_key(required),
"hasgard.screenshot_native must advertise `{required}` in its schema"
);
}
let required = tool.input_schema.get("required").and_then(Value::as_array).expect("required list present");
let required: Vec<&str> = required.iter().filter_map(Value::as_str).collect();
assert!(required.contains(&"window_id"), "hasgard.screenshot_native must require `window_id`");
assert!(required.contains(&"output_path"), "hasgard.screenshot_native must require `output_path`");
for forbidden in ["bytes", "base64", "data"] {
assert!(
!props.contains_key(forbidden),
"hasgard.screenshot_native must not advertise `{forbidden}` (path-only contract)"
);
}
}
#[test]
fn hasgard_screenshot_tool_routes_native_method() {
let bridge = cached_tools()
.iter()
.find(|t| t.name == "hasgard.screenshot")
.expect("bridge hasgard.screenshot tool still registered");
let native = cached_tools()
.iter()
.find(|t| t.name == "hasgard.screenshot_native")
.expect("hasgard.screenshot_native tool registered");
assert_ne!(
bridge.input_schema, native.input_schema,
"bridge `hasgard.screenshot` and native `hasgard.screenshot_native` advertise different schemas"
);
}
#[tokio::test]
#[cfg(unix)]
async fn hasgard_screenshot_forwards_native_params_to_jsonrpc() {
let socket =
std::env::temp_dir().join(format!("tauri-hasgard-mcp-screenshot-native-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&socket);
let listener = UnixListener::bind(&socket).expect("bind mock socket");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await.expect("read request");
let request: Request = serde_json::from_str(line.trim()).expect("parse request");
assert_eq!(request.method, "screenshot_native");
let params = request.params.expect("native screenshot params present");
assert_eq!(params["window_id"], json!(42_u64));
assert_eq!(params["output_path"], json!("/tmp/out.png"));
assert_eq!(params["format"], json!("png"));
let response = Response::success(
request.id,
json!({
"output_path": "/tmp/out.png",
"window_id": 42_u32,
"width": 100_u32,
"height": 50_u32,
"scale_factor": 2.0_f32,
"byte_size": 1234_u64,
"backend": "screencapture",
"tcc_denied": false,
}),
);
let mut bytes = serde_json::to_vec(&response).expect("serialize response");
bytes.push(b'\n');
writer.write_all(&bytes).await.expect("write response");
});
let hasgard = HasgardMcpServer::new(Some(socket.clone()), None);
let mut args = Map::new();
args.insert("window_id".to_owned(), json!(42_u32));
args.insert("output_path".to_owned(), json!("/tmp/out.png"));
args.insert("format".to_owned(), json!("png"));
let result = hasgard.call_tool_by_name("screenshot_native", args).await.expect("tool call succeeds");
assert_eq!(result.is_error, Some(false));
server.await.expect("mock server task");
let _ = std::fs::remove_file(&socket);
}
#[test]
fn windows_schema_omits_window_override() {
let schema = global_empty_schema();
let properties = schema.get("properties").and_then(Value::as_object).expect("schema has properties");
assert!(!properties.contains_key("window"));
}
#[test]
fn startup_banner_explains_stdio_server() {
let banner = startup_banner(None, Some("main"));
assert!(banner.contains("tauri-hasgard MCP server"));
assert!(banner.contains("listening on stdio"));
assert!(banner.contains("auto-detect on first tool call"));
assert!(banner.contains("main"));
assert!(banner.contains("stdout is reserved for MCP JSON-RPC"));
}
#[test]
fn dangerous_tools_hidden_by_default() {
let tools = build_tools_with_flag(false);
for dangerous in DANGEROUS_MCP_TOOLS {
let namespaced = namespaced_tool_name(dangerous);
assert!(
!tools.iter().any(|tool| tool.name == namespaced),
"dangerous tool '{dangerous}' must not be listed unless explicitly enabled"
);
}
}
#[test]
fn dangerous_tools_can_be_enabled() {
let tools = build_tools_with_flag(true);
for dangerous in DANGEROUS_MCP_TOOLS {
let namespaced = namespaced_tool_name(dangerous);
assert!(
tools.iter().any(|tool| tool.name == namespaced),
"dangerous tool '{dangerous}' should be listed when explicitly enabled"
);
}
}
#[tokio::test]
async fn navigate_rejects_javascript_urls() {
let payloads = [
" javascript:alert(1)", "JaVaScRiPt:alert(1)", "java\tscript:alert(1)", "java\nscript:alert(1)", "java\rscript:alert(1)", "\u{0}javascript:alert(1)", "\u{1}\u{2}javascript:alert(1)", ];
for payload in payloads {
let hasgard = HasgardMcpServer::new(None, None);
let mut args = Map::new();
args.insert("url".to_owned(), json!(payload));
let err = hasgard
.call_tool_by_name("navigate", args)
.await
.expect_err(&format!("javascript URL should be rejected: {payload:?}"));
assert_eq!(err.code, ErrorCode::INVALID_PARAMS, "payload: {payload:?}");
assert!(
err.message.contains("does not allow javascript: URLs"),
"unexpected error message for {payload:?}: {}",
err.message
);
}
}
#[test]
fn validate_navigate_url_allows_safe_urls() {
for url in
["https://example.com/app", "/relative/path", "https://example.com/javascript:not-a-scheme", "about:blank"]
{
validate_navigate_url(url)
.unwrap_or_else(|err| panic!("safe url wrongly rejected: {url:?} ({})", err.message));
}
}
#[tokio::test]
async fn replay_export_does_not_connect_to_socket() {
let recording = std::env::temp_dir().join(format!("tauri-hasgard-mcp-replay-test-{}.json", std::process::id()));
std::fs::write(&recording, r#"[{"action":"click","timestamp":0,"ref":"e1"}]"#).expect("write recording");
let missing_socket =
std::env::temp_dir().join(format!("tauri-hasgard-mcp-missing-{}.sock", std::process::id()));
let hasgard = HasgardMcpServer::new(Some(missing_socket), None);
let mut args = Map::new();
args.insert("path".to_owned(), json!(recording.display().to_string()));
args.insert("export".to_owned(), json!("sh"));
let result = hasgard.call_tool_by_name("replay", args).await.expect("tool call succeeds");
assert_eq!(result.is_error, Some(false));
let script = result
.structured_content
.as_ref()
.and_then(|content| content.get("result"))
.and_then(Value::as_str)
.expect("script result");
assert!(script.starts_with("#!/bin/bash"));
assert!(script.contains("tauri-hasgard click '@e1'"));
let _ = std::fs::remove_file(&recording);
}
#[tokio::test]
#[serial]
#[cfg(unix)]
async fn auto_detected_socket_is_pinned_after_first_connection() {
use std::os::unix::fs::PermissionsExt;
let dir = std::path::PathBuf::from(format!("/tmp/hg-mcp-pin-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create socket dir");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("make socket dir private");
let old_socket = dir.join("tauri-hasgard-old.sock");
let new_socket = dir.join("tauri-hasgard-new.sock");
let _ = std::fs::remove_file(&old_socket);
let _ = std::fs::remove_file(&new_socket);
let old_server = spawn_click_server(&old_socket, "old", 2);
unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
let hasgard = HasgardMcpServer::new(None, None);
let first = call_click(&hasgard).await;
assert_eq!(tool_result_source(&first), Some("old"));
let new_server = spawn_click_server(&new_socket, "new", 1);
let second = call_click(&hasgard).await;
assert_eq!(tool_result_source(&second), Some("old"));
unsafe { std::env::remove_var("XDG_RUNTIME_DIR") };
old_server.await.expect("old mock server task");
new_server.abort();
let _ = std::fs::remove_file(&old_socket);
let _ = std::fs::remove_file(&new_socket);
let _ = std::fs::remove_dir(&dir);
}
#[tokio::test]
#[cfg(unix)]
async fn click_tool_sends_json_rpc_request() {
let socket = std::env::temp_dir().join(format!("tauri-hasgard-mcp-click-test-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&socket);
let listener = UnixListener::bind(&socket).expect("bind mock socket");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await.expect("read request");
let request: Request = serde_json::from_str(line.trim()).expect("parse request");
assert_eq!(request.method, "click");
assert_eq!(request.params, Some(json!({"ref": "e3"})));
let mut response =
serde_json::to_vec(&Response::success(request.id, json!({"ok": true}))).expect("serialize response");
response.push(b'\n');
writer.write_all(&response).await.expect("write response");
});
let hasgard = HasgardMcpServer::new(Some(socket.clone()), None);
let mut args = Map::new();
args.insert("target".to_owned(), json!("@e3"));
let result = hasgard.call_tool_by_name("click", args).await.expect("tool call succeeds");
assert_eq!(result.is_error, Some(false));
assert_eq!(result.structured_content, Some(json!({"result": {"ok": true}})));
server.await.expect("mock server task");
let _ = std::fs::remove_file(&socket);
}
#[cfg(unix)]
async fn call_click(hasgard: &HasgardMcpServer) -> CallToolResult {
let mut args = Map::new();
args.insert("target".to_owned(), json!("@e3"));
hasgard.call_tool_by_name("click", args).await.expect("tool call succeeds")
}
#[cfg(unix)]
fn tool_result_source(result: &CallToolResult) -> Option<&str> {
result
.structured_content
.as_ref()
.and_then(|content| content.get("result"))
.and_then(|result| result.get("source"))
.and_then(Value::as_str)
}
#[cfg(unix)]
fn spawn_click_server(socket: &Path, source: &'static str, requests: usize) -> JoinHandle<()> {
let listener = UnixListener::bind(socket).expect("bind mock socket");
tokio::spawn(async move {
for _ in 0..requests {
let (stream, _) = listener.accept().await.expect("accept");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await.expect("read request");
let request: Request = serde_json::from_str(line.trim()).expect("parse request");
assert_eq!(request.method, "click");
let mut response = serde_json::to_vec(&Response::success(request.id, json!({"source": source})))
.expect("serialize response");
response.push(b'\n');
writer.write_all(&response).await.expect("write response");
}
})
}
}