use serde_json::{json, Value};
use crate::mcp::annotations;
use crate::mcp::protocol::{Tool, ToolCallResult};
#[cfg(feature = "watch")]
#[must_use]
pub fn watch_tools() -> Vec<Tool> {
vec![
tool_ax_watch_start(),
tool_ax_watch_stop(),
tool_ax_watch_status(),
]
}
#[cfg(feature = "watch")]
fn tool_ax_watch_start() -> Tool {
Tool {
name: "ax_watch_start",
title: "Start continuous audio/camera monitoring",
description: "Begin background monitoring that pushes events to Claude Code via \
notifications/claude/channel.\n\
\n\
Audio monitoring: captures 5-second windows, applies voice activity detection \
(−40 dBFS threshold), transcribes speech on-device via SFSpeechRecognizer \
(macOS, no cloud), and emits [speech detected] notifications.\n\
\n\
Camera monitoring: captures one frame every 2 seconds, detects motion via JPEG \
size heuristic, runs Vision gesture detection when motion is found, and emits \
[gesture detected] notifications. The camera indicator light is ON during captures.\n\
\n\
Memory: at most ~2 MB of binary data in RAM at any time (one audio window + two \
camera frames). Events are small text strings (bounded channel of 100).\n\
\n\
Call ax_watch_stop to halt monitoring.",
input_schema: json!({
"type": "object",
"properties": {
"audio": {
"type": "boolean",
"description": "Enable audio capture and speech transcription (default false)",
"default": false
},
"camera": {
"type": "boolean",
"description": "Enable camera capture and gesture detection (default false)",
"default": false
},
"vad_threshold_db": {
"type": "number",
"description": "Voice activity threshold in dBFS. Audio below this is ignored. Default −40.0.",
"default": -40.0,
"minimum": -96.0,
"maximum": 0.0
},
"camera_interval_ms": {
"type": "integer",
"description": "Milliseconds between camera frame captures. Default 2000.",
"default": 2000,
"minimum": 500,
"maximum": 30000
}
},
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"started": { "type": "boolean" },
"audio_enabled": { "type": "boolean" },
"camera_enabled": { "type": "boolean" },
"message": { "type": "string" }
},
"required": ["started", "audio_enabled", "camera_enabled"]
}),
annotations: annotations::ACTION,
}
}
#[cfg(feature = "watch")]
fn tool_ax_watch_stop() -> Tool {
Tool {
name: "ax_watch_stop",
title: "Stop all active watchers",
description: "Stop the background audio and camera watchers started by ax_watch_start. \
All in-flight captures complete before the tasks exit — no data is lost mid-window.\n\
Safe to call even if no watchers are running (no-op).",
input_schema: json!({
"type": "object",
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"stopped": { "type": "boolean" },
"message": { "type": "string" }
},
"required": ["stopped"]
}),
annotations: annotations::ACTION,
}
}
#[cfg(feature = "watch")]
fn tool_ax_watch_status() -> Tool {
Tool {
name: "ax_watch_status",
title: "Check watch monitoring status",
description: "Return whether the audio and camera watchers are currently running.",
input_schema: json!({
"type": "object",
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"audio_running": { "type": "boolean" },
"camera_running": { "type": "boolean" }
},
"required": ["audio_running", "camera_running"]
}),
annotations: annotations::READ_ONLY,
}
}
#[cfg(feature = "watch")]
pub(crate) fn handle_ax_watch_start(args: &Value, state: &WatchState) -> ToolCallResult {
let audio = args["audio"].as_bool().unwrap_or(false);
let camera = args["camera"].as_bool().unwrap_or(false);
if !audio && !camera {
return ToolCallResult::error("At least one of 'audio' or 'camera' must be true");
}
let vad_threshold_db = args["vad_threshold_db"].as_f64().unwrap_or(-40.0) as f32;
let camera_interval_ms = args["camera_interval_ms"].as_u64().unwrap_or(2000);
let config = crate::watch::WatchConfig {
audio_enabled: audio,
camera_enabled: camera,
audio_vad_threshold_db: vad_threshold_db,
camera_poll_interval_ms: camera_interval_ms,
..crate::watch::WatchConfig::default()
};
let _rx = state.start(config);
ToolCallResult::ok(
json!({
"started": true,
"audio_enabled": audio,
"camera_enabled": camera,
"message": format!(
"Watch started: audio={audio}, camera={camera}, \
vad={vad_threshold_db:.1} dB, camera_interval={camera_interval_ms} ms"
)
})
.to_string(),
)
}
#[cfg(feature = "watch")]
pub(crate) fn handle_ax_watch_stop(state: &WatchState) -> ToolCallResult {
state.stop();
ToolCallResult::ok(json!({ "stopped": true, "message": "All watchers stopped" }).to_string())
}
#[cfg(feature = "watch")]
pub(crate) fn handle_ax_watch_status(state: &WatchState) -> ToolCallResult {
let status = state.status();
ToolCallResult::ok(
json!({
"audio_running": status.audio_running,
"camera_running": status.camera_running,
})
.to_string(),
)
}
#[cfg(feature = "watch")]
pub struct WatchState {
inner: std::sync::Mutex<WatchStateInner>,
}
#[cfg(feature = "watch")]
struct WatchStateInner {
coordinator: Option<crate::watch::WatchCoordinator>,
pending_rx: Option<tokio::sync::mpsc::Receiver<crate::watch::WatchEvent>>,
}
#[cfg(feature = "watch")]
impl WatchState {
#[must_use]
pub fn new() -> Self {
Self {
inner: std::sync::Mutex::new(WatchStateInner {
coordinator: None,
pending_rx: None,
}),
}
}
pub fn start(
&self,
config: crate::watch::WatchConfig,
) -> tokio::sync::mpsc::Receiver<crate::watch::WatchEvent> {
let mut guard = self.inner.lock().expect("watch state lock poisoned");
if let Some(old) = guard.coordinator.take() {
old.cancel.cancel();
}
let (coordinator, event_rx) = crate::watch::WatchCoordinator::start(config);
guard.coordinator = Some(coordinator);
guard.pending_rx = Some(event_rx);
let (_, dummy_rx) = tokio::sync::mpsc::channel(1);
dummy_rx
}
pub fn take_pending_receiver(
&self,
) -> Option<tokio::sync::mpsc::Receiver<crate::watch::WatchEvent>> {
self.inner
.lock()
.expect("watch state lock poisoned")
.pending_rx
.take()
}
pub fn stop(&self) {
let mut guard = self.inner.lock().expect("watch state lock poisoned");
if let Some(coord) = guard.coordinator.take() {
coord.cancel.cancel();
}
}
#[must_use]
pub fn status(&self) -> crate::watch::WatchStatus {
let guard = self.inner.lock().expect("watch state lock poisoned");
guard.coordinator.as_ref().map_or(
crate::watch::WatchStatus {
audio_running: false,
camera_running: false,
},
|c| c.status(),
)
}
}
#[cfg(feature = "watch")]
impl Default for WatchState {
fn default() -> Self {
Self::new()
}
}
#[cfg(all(test, feature = "watch"))]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn watch_tools_returns_three_tools() {
let tools = watch_tools();
assert_eq!(tools.len(), 3);
let names: Vec<&str> = tools.iter().map(|t| t.name).collect();
assert!(names.contains(&"ax_watch_start"));
assert!(names.contains(&"ax_watch_stop"));
assert!(names.contains(&"ax_watch_status"));
}
#[test]
fn watch_tools_all_have_non_empty_descriptions() {
for tool in watch_tools() {
assert!(
!tool.description.is_empty(),
"empty description on {}",
tool.name
);
}
}
#[test]
fn watch_tools_names_are_unique() {
let tools = watch_tools();
let names: std::collections::HashSet<&str> = tools.iter().map(|t| t.name).collect();
assert_eq!(names.len(), tools.len(), "duplicate tool names");
}
#[test]
fn ax_watch_start_requires_at_least_one_sensor() {
let state = WatchState::new();
let args = json!({ "audio": false, "camera": false });
let result = handle_ax_watch_start(&args, &state);
assert!(result.is_error);
assert!(result.content[0].text.contains("audio"));
}
#[tokio::test]
async fn ax_watch_stop_no_op_when_nothing_running() {
let state = WatchState::new();
let result = handle_ax_watch_stop(&state);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["stopped"], true);
}
#[tokio::test]
async fn ax_watch_status_reports_nothing_running_initially() {
let state = WatchState::new();
let result = handle_ax_watch_status(&state);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["audio_running"], false);
assert_eq!(v["camera_running"], false);
}
#[tokio::test]
async fn watch_state_start_stop_roundtrip() {
let state = WatchState::new();
let config = crate::watch::WatchConfig {
audio_enabled: false, camera_enabled: false,
..crate::watch::WatchConfig::default()
};
let _rx = state.start(config);
let status = state.status();
assert!(!status.audio_running);
assert!(!status.camera_running);
state.stop();
}
#[tokio::test]
async fn ax_watch_start_applies_custom_vad_threshold() {
let state = WatchState::new();
let args = json!({ "audio": true, "camera": false, "vad_threshold_db": -30.0 });
let result = handle_ax_watch_start(&args, &state);
assert!(!result.is_error, "{}", result.content[0].text);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["started"], true);
assert!(v["message"].as_str().unwrap().contains("-30.0"));
state.stop();
}
#[tokio::test]
async fn ax_watch_start_applies_custom_camera_interval() {
let state = WatchState::new();
let args = json!({ "audio": false, "camera": true, "camera_interval_ms": 5000 });
let result = handle_ax_watch_start(&args, &state);
assert!(!result.is_error, "{}", result.content[0].text);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert!(v["message"].as_str().unwrap().contains("5000"));
state.stop();
}
}