use super::*;
use serde_json::json;
fn make_request(id: i64, method: &str, params: Option<Value>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".into(),
id: Some(RequestId::Number(id)),
method: method.into(),
params,
}
}
fn make_notification(method: &str) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".into(),
id: None,
method: method.into(),
params: None,
}
}
fn initialize_server(s: &mut Server) {
let req = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": { "name": "test", "version": "0.1" }
})),
);
let mut sink = Vec::<u8>::new();
s.handle(&req, &mut sink);
s.handle_notification(&make_notification("notifications/initialized"));
}
fn send(s: &mut Server, id: i64, method: &str, params: Option<Value>) -> Value {
let req = make_request(id, method, params);
let mut sink = Vec::<u8>::new();
let resp = s.handle(&req, &mut sink).unwrap();
serde_json::to_value(&resp).unwrap()
}
#[test]
fn server_starts_uninitialized() {
let s = Server::new();
assert_eq!(s.phase, Phase::Uninitialized);
}
#[test]
fn initialize_request_transitions_to_initializing() {
let mut s = Server::new();
let req = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
assert_eq!(s.phase, Phase::Initializing);
let v: Value = serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
assert_eq!(v["result"]["serverInfo"]["name"], "axterminator");
}
#[test]
fn initialized_notification_transitions_to_running() {
let mut s = Server::new();
let req = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
s.handle(&req, &mut Vec::<u8>::new());
assert_eq!(s.phase, Phase::Initializing);
s.handle_notification(&make_notification("notifications/initialized"));
assert_eq!(s.phase, Phase::Running);
}
#[test]
fn ping_returns_empty_object() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(2, "ping", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert_eq!(v["result"], json!({}));
}
#[test]
fn tools_list_returns_correct_count_for_feature_set() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(3, "tools/list", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let count = v["result"]["tools"].as_array().unwrap().len();
let base = 34usize; let context_base = 1usize; let extra_context_location: usize = if cfg!(feature = "context") { 1 } else { 0 };
let extra_spaces: usize = if cfg!(feature = "spaces") { 5 } else { 0 };
let extra_audio: usize = if cfg!(feature = "audio") { 8 } else { 0 };
let extra_camera: usize = if cfg!(feature = "camera") { 3 } else { 0 };
let extra_watch: usize = if cfg!(feature = "watch") { 3 } else { 0 };
let extra_docker: usize = if cfg!(feature = "docker") { 2 } else { 0 };
assert_eq!(
count,
base + context_base
+ extra_context_location
+ extra_spaces
+ extra_audio
+ extra_camera
+ extra_watch
+ extra_docker
);
}
#[test]
fn tools_list_before_initialized_returns_error() {
let mut s = Server::new();
let req = make_request(1, "tools/list", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn unknown_method_returns_method_not_found() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(4, "sampling/createMessage", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert_eq!(v["error"]["code"], RpcError::METHOD_NOT_FOUND);
}
#[test]
fn notification_returns_none() {
let mut s = Server::new();
initialize_server(&mut s);
let notif = make_notification("notifications/cancelled");
let resp = s.handle(¬if, &mut Vec::<u8>::new());
assert!(resp.is_none());
}
#[test]
fn tools_call_is_accessible_succeeds() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
5,
"tools/call",
Some(json!({ "name": "ax_is_accessible", "arguments": {} })),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v["result"]["content"].is_array());
}
#[test]
fn invalid_initialize_params_returns_error() {
let mut s = Server::new();
let req = make_request(1, "initialize", Some(json!({"bad": "data"})));
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn initialize_response_advertises_resources_capability() {
let mut s = Server::new();
let req = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v["result"]["capabilities"]["resources"].is_object());
assert_eq!(v["result"]["capabilities"]["resources"]["subscribe"], true);
}
#[test]
fn initialize_response_advertises_prompts_capability() {
let mut s = Server::new();
let req = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v["result"]["capabilities"]["prompts"].is_object());
}
#[test]
fn initialize_response_advertises_elicitation_capability() {
let mut s = Server::new();
let req = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v["result"]["capabilities"]["elicitation"].is_object());
}
#[test]
fn server_handle_ping_returns_empty_object() {
let mut h = ServerHandle::new();
let init = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
h.handle(&init, &mut Vec::<u8>::new());
h.handle(
&make_notification("notifications/initialized"),
&mut Vec::<u8>::new(),
);
let req = make_request(2, "ping", None);
let resp = h.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert_eq!(v["result"], json!({}));
}
#[test]
fn server_handle_default_creates_uninitialized_instance() {
let mut h = ServerHandle::default();
let req = make_request(1, "tools/list", None);
let resp = h.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn resources_list_returns_static_resources() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(10, "resources/list", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let resources = v["result"]["resources"].as_array().unwrap();
assert!(!resources.is_empty());
let has_status = resources
.iter()
.any(|r| r["uri"] == "axterminator://system/status");
assert!(has_status);
}
#[test]
fn resources_list_before_initialized_returns_error() {
let mut s = Server::new();
let req = make_request(10, "resources/list", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn resources_templates_list_returns_templates() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(11, "resources/templates/list", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let templates = v["result"]["resourceTemplates"].as_array().unwrap();
assert!(!templates.is_empty());
let has_tree = templates
.iter()
.any(|t| t["uriTemplate"] == "axterminator://app/{name}/tree");
assert!(has_tree);
}
#[test]
fn resources_read_system_status_returns_contents() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
12,
"resources/read",
Some(json!({ "uri": "axterminator://system/status" })),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let contents = v["result"]["contents"].as_array().unwrap();
assert_eq!(contents.len(), 1);
assert!(contents[0]["text"].as_str().is_some());
}
#[test]
fn resources_read_missing_params_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(13, "resources/read", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn resources_read_unconnected_app_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
14,
"resources/read",
Some(json!({ "uri": "axterminator://app/NotConnected/tree" })),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn prompts_list_returns_ten_prompts() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(20, "prompts/list", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let prompts = v["result"]["prompts"].as_array().unwrap();
assert_eq!(prompts.len(), 10);
}
#[test]
fn prompts_list_before_initialized_returns_error() {
let mut s = Server::new();
let req = make_request(20, "prompts/list", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn prompts_get_test_app_returns_messages() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
21,
"prompts/get",
Some(json!({
"name": "test-app",
"arguments": { "app_name": "Safari" }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let msgs = v["result"]["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[1]["role"], "assistant");
}
#[test]
fn prompts_get_unknown_prompt_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
22,
"prompts/get",
Some(json!({ "name": "nonexistent-prompt" })),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn prompts_get_missing_required_arg_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
23,
"prompts/get",
Some(json!({
"name": "navigate-to",
"arguments": { "app_name": "Finder" }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn prompts_get_missing_params_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(24, "prompts/get", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v.get("error").is_some());
}
#[test]
fn initialize_response_advertises_tasks_capability() {
let mut s = Server::new();
let req = make_request(
1,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v["result"]["capabilities"]["tasks"].is_object());
}
#[test]
fn tasks_list_returns_empty_on_fresh_server() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 30, "tasks/list", None);
let tasks = v["result"]["tasks"].as_array().unwrap();
assert!(tasks.is_empty());
}
#[test]
fn tasks_list_before_initialized_returns_error() {
let mut s = Server::new();
let v = send(&mut s, 30, "tasks/list", None);
assert!(v.get("error").is_some());
}
#[test]
fn tools_call_with_meta_task_returns_working_status() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
31,
"tools/call",
Some(json!({
"name": "ax_is_accessible",
"arguments": {},
"_meta": { "task": true }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let task = &v["result"]["task"];
assert!(task.is_object(), "expected task object in result");
assert_eq!(task["status"], "working");
assert!(task["taskId"].as_str().unwrap().starts_with("task-"));
}
#[test]
fn tasks_list_shows_task_after_async_call() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
32,
"tools/call",
Some(json!({
"name": "ax_is_accessible",
"arguments": {},
"_meta": { "task": true }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let task_id = v["result"]["task"]["taskId"].as_str().unwrap().to_owned();
std::thread::sleep(std::time::Duration::from_millis(100));
let list_v = send(&mut s, 33, "tasks/list", None);
let tasks = list_v["result"]["tasks"].as_array().unwrap();
assert!(!tasks.is_empty());
let found = tasks.iter().any(|t| t["taskId"] == task_id);
assert!(found, "task {task_id} missing from tasks/list");
}
#[test]
fn tasks_result_returns_pending_while_working() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
34,
"tools/call",
Some(json!({
"name": "ax_is_accessible",
"arguments": {},
"_meta": { "task": true }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let task_id = v["result"]["task"]["taskId"].as_str().unwrap().to_owned();
let result_v = send(
&mut s,
35,
"tasks/result",
Some(json!({ "taskId": task_id })),
);
assert!(
result_v.get("error").is_none(),
"tasks/result for a known task should not error"
);
}
#[test]
fn tasks_result_for_completed_task_returns_tool_result() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
36,
"tools/call",
Some(json!({
"name": "ax_is_accessible",
"arguments": {},
"_meta": { "task": true }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let task_id = v["result"]["task"]["taskId"].as_str().unwrap().to_owned();
std::thread::sleep(std::time::Duration::from_millis(200));
let result_v = send(
&mut s,
37,
"tasks/result",
Some(json!({ "taskId": task_id })),
);
let result = &result_v["result"];
assert!(
result.get("content").is_some(),
"completed task should return tool result with content"
);
}
#[test]
fn tasks_result_unknown_task_id_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
38,
"tasks/result",
Some(json!({ "taskId": "task-nonexistent" })),
);
assert!(v.get("error").is_some());
assert_eq!(v["error"]["code"], -32_602);
}
#[test]
fn tasks_result_missing_params_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 39, "tasks/result", None);
assert!(v.get("error").is_some());
}
#[test]
fn tasks_cancel_working_task_marks_it_cancelled() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
40,
"tools/call",
Some(json!({
"name": "ax_is_accessible",
"arguments": {},
"_meta": { "task": true }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let task_id = v["result"]["task"]["taskId"].as_str().unwrap().to_owned();
let cancel_v = send(
&mut s,
41,
"tasks/cancel",
Some(json!({ "taskId": task_id })),
);
assert!(cancel_v.get("error").is_none());
assert_eq!(cancel_v["result"], json!({}));
std::thread::sleep(std::time::Duration::from_millis(100));
let list_v = send(&mut s, 42, "tasks/list", None);
let tasks = list_v["result"]["tasks"].as_array().unwrap();
let task = tasks.iter().find(|t| t["taskId"] == task_id).unwrap();
let status = task["status"].as_str().unwrap();
assert!(
status == "cancelled" || status == "done" || status == "failed",
"task should be in a terminal state, got: {status}"
);
}
#[test]
fn tasks_cancel_unknown_task_id_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
43,
"tasks/cancel",
Some(json!({ "taskId": "task-does-not-exist" })),
);
assert!(v.get("error").is_some());
assert_eq!(v["error"]["code"], -32_602);
}
#[test]
fn tasks_cancel_missing_params_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 44, "tasks/cancel", None);
assert!(v.get("error").is_some());
}
#[test]
fn tools_call_without_meta_task_executes_synchronously() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
50,
"tools/call",
Some(json!({ "name": "ax_is_accessible", "arguments": {} })),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(v["result"].get("task").is_none());
assert!(v["result"]["content"].is_array());
}
#[test]
fn task_id_format_is_zero_padded_hex_prefix() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(
60,
"tools/call",
Some(json!({
"name": "ax_is_accessible",
"arguments": {},
"_meta": { "task": true }
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
let task_id = v["result"]["task"]["taskId"].as_str().unwrap();
assert!(task_id.starts_with("task-"));
let digits = &task_id[5..];
assert_eq!(digits.len(), 16, "task ID should have 16 digit suffix");
assert!(digits.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn initialize_response_advertises_sampling_capability() {
let mut s = Server::new();
let req = make_request(
70,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1"}
})),
);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert!(
v["result"]["capabilities"]["sampling"].is_object(),
"expected sampling capability object in server capabilities"
);
}
#[test]
fn client_supports_sampling_false_when_not_advertised() {
let mut s = Server::new();
let req = make_request(
71,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": {"name": "no-sampling-client", "version": "1"}
})),
);
s.handle(&req, &mut Vec::<u8>::new());
assert!(
!s.client_supports_sampling,
"client_supports_sampling should be false when client omits sampling capability"
);
}
#[test]
fn client_supports_sampling_true_when_advertised() {
let mut s = Server::new();
let req = make_request(
72,
"initialize",
Some(json!({
"protocolVersion": "2025-11-05",
"capabilities": {
"sampling": { "createMessage": {} }
},
"clientInfo": {"name": "claude-code", "version": "2.0"}
})),
);
s.handle(&req, &mut Vec::<u8>::new());
assert!(
s.client_supports_sampling,
"client_supports_sampling should be true when client advertises sampling"
);
}
#[test]
fn client_supports_sampling_false_by_default_before_initialize() {
let s = Server::new();
assert!(!s.client_supports_sampling);
}
#[test]
fn sampling_create_message_is_method_not_found_from_server_side() {
let mut s = Server::new();
initialize_server(&mut s);
let req = make_request(73, "sampling/createMessage", None);
let resp = s.handle(&req, &mut Vec::<u8>::new()).unwrap();
let v: Value = serde_json::to_value(&resp).unwrap();
assert_eq!(v["error"]["code"], RpcError::METHOD_NOT_FOUND);
}
#[test]
fn tools_list_returns_exactly_34_base_tools_with_default_features() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 100, "tools/list", None);
let tools = v["result"]["tools"].as_array().unwrap();
let base: usize = 35; let extra_context_location: usize = if cfg!(feature = "context") { 1 } else { 0 };
let extra_spaces: usize = if cfg!(feature = "spaces") { 5 } else { 0 };
let extra_audio: usize = if cfg!(feature = "audio") { 8 } else { 0 };
let extra_camera: usize = if cfg!(feature = "camera") { 3 } else { 0 };
let extra_watch: usize = if cfg!(feature = "watch") { 3 } else { 0 };
let extra_docker: usize = if cfg!(feature = "docker") { 2 } else { 0 };
let expected = base
+ extra_context_location
+ extra_spaces
+ extra_audio
+ extra_camera
+ extra_watch
+ extra_docker;
assert_eq!(
tools.len(),
expected,
"expected {expected} tools but got {}; base=35 + context_loc={extra_context_location} + spaces={extra_spaces} + \
audio={extra_audio} + camera={extra_camera} + watch={extra_watch} + docker={extra_docker}",
tools.len()
);
}
#[test]
fn prompts_list_contains_all_ten_expected_prompt_names() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 101, "prompts/list", None);
let prompts = v["result"]["prompts"].as_array().unwrap();
let names: Vec<&str> = prompts
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
let expected = [
"test-app",
"navigate-to",
"extract-data",
"accessibility-audit",
"troubleshooting",
"app-guide",
"automate-workflow",
"debug-ui",
"cross-app-copy",
"analyze-app",
];
for name in &expected {
assert!(
names.contains(name),
"prompt '{name}' missing from prompts/list; found: {names:?}"
);
}
assert_eq!(
prompts.len(),
10,
"expected exactly 10 prompts, got {}",
prompts.len()
);
}
fn assert_prompt_resolves(s: &mut Server, id: i64, name: &str, args: Value) {
let v = send(
s,
id,
"prompts/get",
Some(json!({ "name": name, "arguments": args })),
);
assert!(
v.get("error").is_none(),
"prompt '{name}' returned an error: {:?}",
v.get("error")
);
let msgs = v["result"]["messages"].as_array().unwrap_or_else(|| {
panic!(
"prompt '{name}' result missing 'messages' array; result={:?}",
v["result"]
)
});
assert!(
!msgs.is_empty(),
"prompt '{name}' returned an empty messages array"
);
}
#[test]
fn prompts_get_navigate_to_resolves_with_both_args() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(
&mut s,
102,
"navigate-to",
json!({ "app_name": "Safari", "target_screen": "Settings" }),
);
}
#[test]
fn prompts_get_extract_data_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(
&mut s,
103,
"extract-data",
json!({ "app_name": "Safari", "data_description": "links" }),
);
}
#[test]
fn prompts_get_accessibility_audit_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(
&mut s,
104,
"accessibility-audit",
json!({ "app_name": "Safari" }),
);
}
#[test]
fn prompts_get_troubleshooting_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(
&mut s,
105,
"troubleshooting",
json!({ "error": "Element not found" }),
);
}
#[test]
fn prompts_get_app_guide_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(&mut s, 106, "app-guide", json!({ "app": "Calculator" }));
}
#[test]
fn prompts_get_automate_workflow_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(
&mut s,
107,
"automate-workflow",
json!({ "app_name": "Safari", "goal": "login" }),
);
}
#[test]
fn prompts_get_debug_ui_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(
&mut s,
108,
"debug-ui",
json!({ "app_name": "Safari", "query": "Save" }),
);
}
#[test]
fn prompts_get_cross_app_copy_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(
&mut s,
109,
"cross-app-copy",
json!({
"source_app": "Safari",
"dest_app": "Notes",
"data_description": "URL"
}),
);
}
#[test]
fn prompts_get_analyze_app_resolves() {
let mut s = Server::new();
initialize_server(&mut s);
assert_prompt_resolves(&mut s, 110, "analyze-app", json!({ "app_name": "Safari" }));
}
#[test]
fn resources_list_returns_six_static_resources_with_default_features() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 111, "resources/list", None);
let resources = v["result"]["resources"].as_array().unwrap();
let base: usize = 6;
let extra_spaces: usize = if cfg!(feature = "spaces") { 1 } else { 0 };
let extra_audio: usize = if cfg!(feature = "audio") { 4 } else { 0 };
let extra_camera: usize = if cfg!(feature = "camera") { 1 } else { 0 };
let expected = base + extra_spaces + extra_audio + extra_camera;
assert_eq!(
resources.len(),
expected,
"expected {expected} static resources, got {}",
resources.len()
);
}
#[test]
fn resources_list_contains_all_six_base_static_uris() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 112, "resources/list", None);
let resources = v["result"]["resources"].as_array().unwrap();
let uris: Vec<&str> = resources
.iter()
.map(|r| r["uri"].as_str().unwrap())
.collect();
let base_uris = [
"axterminator://system/status",
"axterminator://system/displays",
"axterminator://apps",
"axterminator://clipboard",
"axterminator://workflows",
"axterminator://profiles",
];
for uri in &base_uris {
assert!(
uris.contains(uri),
"static resource '{uri}' missing from resources/list; found: {uris:?}"
);
}
}
#[test]
fn resources_templates_list_returns_exactly_four_templates() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 113, "resources/templates/list", None);
let templates = v["result"]["resourceTemplates"].as_array().unwrap();
assert_eq!(
templates.len(),
4,
"expected 4 resource templates, got {}",
templates.len()
);
}
#[test]
fn resources_templates_list_contains_all_four_template_uris() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 114, "resources/templates/list", None);
let templates = v["result"]["resourceTemplates"].as_array().unwrap();
let tmpl_uris: Vec<&str> = templates
.iter()
.map(|t| t["uriTemplate"].as_str().unwrap())
.collect();
let expected_templates = [
"axterminator://app/{name}/tree",
"axterminator://app/{name}/screenshot",
"axterminator://app/{name}/state",
"axterminator://app/{name}/query/{question}",
];
for tmpl in &expected_templates {
assert!(
tmpl_uris.contains(tmpl),
"resource template '{tmpl}' missing; found: {tmpl_uris:?}"
);
}
}
#[test]
fn resources_read_system_displays_returns_contents() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
115,
"resources/read",
Some(json!({ "uri": "axterminator://system/displays" })),
);
assert!(
v.get("error").is_some() || v["result"]["contents"].is_array(),
"resources/read system/displays should return contents or an error, got: {v:?}"
);
}
#[test]
fn resources_read_unknown_uri_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
116,
"resources/read",
Some(json!({ "uri": "axterminator://unknown/resource" })),
);
assert!(
v.get("error").is_some(),
"unknown resource URI should return an error"
);
}
#[test]
fn resources_subscribe_returns_empty_success_object() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
120,
"resources/subscribe",
Some(json!({ "uri": "axterminator://system/status" })),
);
assert!(
v.get("error").is_none(),
"subscribe should not error: {v:?}"
);
assert_eq!(
v["result"],
json!({}),
"subscribe result should be an empty object"
);
}
#[test]
fn resources_unsubscribe_after_subscribe_returns_success() {
let mut s = Server::new();
initialize_server(&mut s);
let uri = "axterminator://system/status";
send(
&mut s,
121,
"resources/subscribe",
Some(json!({ "uri": uri })),
);
let v = send(
&mut s,
122,
"resources/unsubscribe",
Some(json!({ "uri": uri })),
);
assert!(
v.get("error").is_none(),
"unsubscribe should not error: {v:?}"
);
assert_eq!(v["result"], json!({}));
}
#[test]
fn resources_unsubscribe_without_prior_subscribe_is_idempotent() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
123,
"resources/unsubscribe",
Some(json!({ "uri": "axterminator://apps" })),
);
assert!(
v.get("error").is_none(),
"unsubscribe for unregistered URI should not error: {v:?}"
);
}
#[test]
fn resources_subscribe_before_initialized_returns_error() {
let mut s = Server::new();
let v = send(
&mut s,
124,
"resources/subscribe",
Some(json!({ "uri": "axterminator://system/status" })),
);
assert!(v.get("error").is_some());
}
#[test]
fn resources_subscribe_missing_params_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 125, "resources/subscribe", None);
assert!(v.get("error").is_some());
assert_eq!(v["error"]["code"], RpcError::INVALID_PARAMS);
}
#[test]
fn security_mode_sandboxed_filters_tools_list_to_read_only_set() {
#[allow(unsafe_code)]
unsafe {
std::env::set_var("AXTERMINATOR_SECURITY_MODE", "sandboxed");
}
let mut s = Server::new();
#[allow(unsafe_code)]
unsafe {
std::env::remove_var("AXTERMINATOR_SECURITY_MODE");
}
initialize_server(&mut s);
let v = send(&mut s, 130, "tools/list", None);
let tools = v["result"]["tools"].as_array().unwrap();
let full_count = 34usize
+ if cfg!(feature = "spaces") { 5 } else { 0 }
+ if cfg!(feature = "audio") { 3 } else { 0 }
+ if cfg!(feature = "camera") { 3 } else { 0 }
+ if cfg!(feature = "watch") { 3 } else { 0 }
+ if cfg!(feature = "docker") { 2 } else { 0 };
assert!(
tools.len() < full_count,
"sandboxed mode should expose fewer tools than the full set ({full_count}), \
but got {}",
tools.len()
);
let read_only_names = [
"ax_is_accessible",
"ax_connect",
"ax_list_apps",
"ax_find",
"ax_find_visual",
"ax_get_tree",
"ax_get_attributes",
"ax_screenshot",
"ax_get_value",
"ax_list_windows",
"ax_assert",
"ax_wait_idle",
"ax_query",
"ax_analyze",
"ax_app_profile",
"ax_watch_start",
"ax_watch_stop",
"ax_watch_status",
];
for tool in tools {
let name = tool["name"].as_str().unwrap();
assert!(
read_only_names.contains(&name),
"sandboxed tools/list contains non-read-only tool '{name}'"
);
}
}
#[test]
fn every_tool_has_annotation_object_in_tools_list() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 140, "tools/list", None);
let tools = v["result"]["tools"].as_array().unwrap();
for tool in tools {
let name = tool["name"].as_str().unwrap_or("<unknown>");
let annotations = &tool["annotations"];
assert!(
annotations.is_object(),
"tool '{name}' missing annotations object; got: {annotations:?}"
);
let obj = annotations.as_object().unwrap();
let has_hint = obj.contains_key("readOnlyHint")
|| obj.contains_key("destructiveHint")
|| obj.contains_key("idempotentHint")
|| obj.contains_key("openWorldHint");
assert!(
has_hint,
"tool '{name}' annotations object has no recognised hint fields: {obj:?}"
);
}
}
#[test]
fn every_tool_annotation_read_only_is_boolean() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 141, "tools/list", None);
let tools = v["result"]["tools"].as_array().unwrap();
for tool in tools {
let name = tool["name"].as_str().unwrap_or("<unknown>");
if let Some(hint) = tool["annotations"].get("readOnlyHint") {
assert!(
hint.is_boolean(),
"tool '{name}' readOnlyHint is not a boolean: {hint:?}"
);
}
}
}
#[test]
fn tasks_list_is_empty_before_any_async_call() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 150, "tasks/list", None);
let tasks = v["result"]["tasks"].as_array().unwrap();
assert!(
tasks.is_empty(),
"tasks/list should be empty on a fresh server, got: {tasks:?}"
);
}
#[test]
fn tasks_list_grows_after_async_submission() {
let mut s = Server::new();
initialize_server(&mut s);
let before = send(&mut s, 151, "tasks/list", None);
let count_before = before["result"]["tasks"].as_array().unwrap().len();
let v = send(
&mut s,
152,
"tools/call",
Some(json!({
"name": "ax_is_accessible",
"arguments": {},
"_meta": { "task": true }
})),
);
assert!(v["result"]["task"].is_object());
std::thread::sleep(std::time::Duration::from_millis(50));
let after = send(&mut s, 153, "tasks/list", None);
let count_after = after["result"]["tasks"].as_array().unwrap().len();
assert_eq!(
count_after,
count_before + 1,
"tasks/list count should grow from {count_before} to {} after one submission",
count_before + 1
);
}
#[test]
fn resources_subscribe_returns_empty_object() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
200,
"resources/subscribe",
Some(json!({ "uri": "axterminator://system/status" })),
);
assert!(v.get("error").is_none(), "subscribe must not error");
assert_eq!(
v["result"],
json!({}),
"subscribe result must be empty object"
);
}
#[test]
fn resources_subscribe_stores_uri_in_subscriptions_set() {
let mut s = Server::new();
initialize_server(&mut s);
assert!(s.subscriptions.lock().unwrap().is_empty());
let _ = send(
&mut s,
201,
"resources/subscribe",
Some(json!({ "uri": "axterminator://clipboard" })),
);
let subs = s.subscriptions.lock().unwrap();
assert!(
subs.contains("axterminator://clipboard"),
"subscriptions must contain the subscribed URI"
);
}
#[test]
fn resources_unsubscribe_removes_uri_from_subscriptions_set() {
let mut s = Server::new();
initialize_server(&mut s);
let _ = send(
&mut s,
202,
"resources/subscribe",
Some(json!({ "uri": "axterminator://apps" })),
);
assert!(s
.subscriptions
.lock()
.unwrap()
.contains("axterminator://apps"));
let v = send(
&mut s,
203,
"resources/unsubscribe",
Some(json!({ "uri": "axterminator://apps" })),
);
assert!(v.get("error").is_none(), "unsubscribe must not error");
assert_eq!(v["result"], json!({}));
assert!(
!s.subscriptions
.lock()
.unwrap()
.contains("axterminator://apps"),
"URI must be removed after unsubscribe"
);
}
#[test]
fn resources_unsubscribe_missing_params_returns_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(&mut s, 205, "resources/unsubscribe", None);
assert!(v.get("error").is_some());
assert_eq!(v["error"]["code"], -32_602);
}
#[test]
fn resources_unsubscribe_nonexistent_uri_succeeds_without_error() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
206,
"resources/unsubscribe",
Some(json!({ "uri": "axterminator://never/subscribed" })),
);
assert!(
v.get("error").is_none(),
"unsubscribing an unknown URI should not error"
);
assert_eq!(v["result"], json!({}));
}
#[test]
fn resources_subscribe_multiple_uris_all_tracked() {
let mut s = Server::new();
initialize_server(&mut s);
for (id, uri) in [
(210, "axterminator://system/status"),
(211, "axterminator://apps"),
(212, "axterminator://clipboard"),
] {
let v = send(
&mut s,
id,
"resources/subscribe",
Some(json!({ "uri": uri })),
);
assert!(
v.get("error").is_none(),
"subscribe to {uri} must not error"
);
}
let subs = s.subscriptions.lock().unwrap();
assert!(subs.contains("axterminator://system/status"));
assert!(subs.contains("axterminator://apps"));
assert!(subs.contains("axterminator://clipboard"));
assert_eq!(subs.len(), 3);
}
#[test]
fn resources_subscribe_same_uri_twice_is_idempotent() {
let mut s = Server::new();
initialize_server(&mut s);
let _ = send(
&mut s,
213,
"resources/subscribe",
Some(json!({ "uri": "axterminator://system/status" })),
);
let _ = send(
&mut s,
214,
"resources/subscribe",
Some(json!({ "uri": "axterminator://system/status" })),
);
let subs = s.subscriptions.lock().unwrap();
assert_eq!(
subs.len(),
1,
"duplicate subscribe must not create duplicate entries"
);
}
#[test]
fn ax_connect_tool_emits_notification_for_subscribed_apps_uri() {
let mut s = Server::new();
initialize_server(&mut s);
let _ = send(
&mut s,
220,
"resources/subscribe",
Some(json!({ "uri": "axterminator://apps" })),
);
let mut out = Vec::<u8>::new();
let req = make_request(
221,
"tools/call",
Some(json!({ "name": "ax_is_accessible", "arguments": {} })),
);
s.handle(&req, &mut out);
assert!(s
.subscriptions
.lock()
.unwrap()
.contains("axterminator://apps"));
}
#[test]
fn notify_resource_changed_writes_valid_jsonrpc_notification() {
let mut out = Vec::<u8>::new();
crate::mcp::server::notify_resource_changed(&mut out, "axterminator://system/status");
let line = String::from_utf8(out).unwrap();
let line = line.trim();
assert!(!line.is_empty(), "notification must not be empty");
let v: Value = serde_json::from_str(line).expect("notification must be valid JSON");
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["method"], "notifications/resources/updated");
assert_eq!(v["params"]["uri"], "axterminator://system/status");
}
#[cfg(feature = "audio")]
#[test]
fn resources_subscribe_capture_status_stores_uri() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
230,
"resources/subscribe",
Some(json!({ "uri": "axterminator://capture/status" })),
);
assert!(v.get("error").is_none());
assert!(s
.subscriptions
.lock()
.unwrap()
.contains("axterminator://capture/status"));
}
#[cfg(feature = "audio")]
#[test]
fn resources_subscribe_capture_transcription_stores_uri() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
231,
"resources/subscribe",
Some(json!({ "uri": "axterminator://capture/transcription" })),
);
assert!(v.get("error").is_none());
assert!(s
.subscriptions
.lock()
.unwrap()
.contains("axterminator://capture/transcription"));
}
#[cfg(feature = "audio")]
#[test]
fn resources_subscribe_capture_screen_stores_uri() {
let mut s = Server::new();
initialize_server(&mut s);
let v = send(
&mut s,
232,
"resources/subscribe",
Some(json!({ "uri": "axterminator://capture/screen" })),
);
assert!(v.get("error").is_none());
assert!(s
.subscriptions
.lock()
.unwrap()
.contains("axterminator://capture/screen"));
}
#[cfg(feature = "audio")]
#[test]
fn ax_start_capture_subscribed_emits_capture_status_notification() {
let _guard = crate::mcp::tools_capture::session_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let mut s = Server::new();
initialize_server(&mut s);
for (id, uri) in [
(233, "axterminator://capture/status"),
(234, "axterminator://capture/transcription"),
(235, "axterminator://capture/screen"),
] {
let _ = send(
&mut s,
id,
"resources/subscribe",
Some(json!({ "uri": uri })),
);
}
let mut out = Vec::<u8>::new();
let req = make_request(
236,
"tools/call",
Some(json!({
"name": "ax_start_capture",
"arguments": {
"audio": false, "transcribe": false, "screen": false, "buffer_seconds": 5
}
})),
);
s.handle(&req, &mut out);
let output = String::from_utf8(out).unwrap();
let notification_count = output
.lines()
.filter(|line| {
serde_json::from_str::<Value>(line)
.map(|v| v["method"] == "notifications/resources/updated")
.unwrap_or(false)
})
.count();
assert!(
notification_count > 0,
"ax_start_capture with subscribed capture URIs must emit at least one notification"
);
let _ = crate::mcp::tools_capture::handle_ax_stop_capture(&json!({}));
}
#[cfg(feature = "audio")]
#[test]
fn ax_stop_capture_subscribed_emits_capture_status_notification() {
let _guard = crate::mcp::tools_capture::session_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ = crate::mcp::tools_capture::handle_ax_start_capture(&json!({
"audio": false, "transcribe": false, "screen": false, "buffer_seconds": 5
}));
let mut s = Server::new();
initialize_server(&mut s);
let _ = send(
&mut s,
240,
"resources/subscribe",
Some(json!({ "uri": "axterminator://capture/status" })),
);
let mut out = Vec::<u8>::new();
let req = make_request(
241,
"tools/call",
Some(json!({ "name": "ax_stop_capture", "arguments": {} })),
);
s.handle(&req, &mut out);
let output = String::from_utf8(out).unwrap();
let has_capture_notification = output.lines().any(|line| {
serde_json::from_str::<Value>(line)
.map(|v| {
v["method"] == "notifications/resources/updated"
&& v["params"]["uri"] == "axterminator://capture/status"
})
.unwrap_or(false)
});
assert!(
has_capture_notification,
"ax_stop_capture with subscribed capture/status must emit notification"
);
}