use rmcp::{
handler::server::wrapper::Parameters,
model::{CallToolResult, Content},
tool, tool_router,
};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::cron_jobs::{
self, CreateRequest, CronStore, HandlerRegistry, ShutdownSignal, UpdateRequest,
};
use crate::routines::{self, CreateRoutineRequest, RoutineStore, UpdateRoutineRequest};
use crate::utils::time::now_secs;
#[derive(Clone)]
pub struct MoadimMcp {
store: CronStore,
handlers: HandlerRegistry,
routines: RoutineStore,
uptime_start: u64,
shutdown: ShutdownSignal,
}
#[derive(Deserialize, JsonSchema)]
struct EchoInput {
message: String,
}
#[derive(Deserialize, JsonSchema)]
struct IdInput {
id: String,
}
#[derive(Deserialize, JsonSchema)]
struct UpdateInput {
id: String,
schedule: Option<String>,
handler: Option<String>,
#[schemars(schema_with = "crate::utils::schema::metadata_schema")]
metadata: Option<serde_json::Value>,
machines: Option<Vec<String>>,
enabled: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
pub(super) struct LocalOnlyParam {
local_only: Option<bool>,
}
#[derive(Deserialize, JsonSchema)]
struct LockRoutinesInput {
scope: String,
}
#[derive(Deserialize, JsonSchema)]
struct UnlockRoutinesInput {
scope: String,
}
#[derive(Deserialize, JsonSchema)]
struct UpdateRoutineInput {
id: String,
schedule: Option<String>,
title: Option<String>,
agent: Option<String>,
prompt: Option<String>,
repositories: Option<Vec<crate::routines::Repository>>,
machines: Option<Vec<String>>,
enabled: Option<bool>,
ttl_secs: Option<u64>,
max_runtime_secs: Option<u64>,
}
fn ok(val: impl serde::Serialize) -> CallToolResult {
CallToolResult::success(vec![Content::text(
serde_json::to_string(&val).unwrap_or_default(),
)])
}
fn err(msg: impl std::fmt::Display) -> CallToolResult {
CallToolResult::error(vec![Content::text(msg.to_string())])
}
#[tool_router(server_handler)]
impl MoadimMcp {
pub fn new(
store: CronStore,
handlers: HandlerRegistry,
routines: RoutineStore,
uptime_start: u64,
shutdown: ShutdownSignal,
) -> Self {
Self {
store,
handlers,
routines,
uptime_start,
shutdown,
}
}
#[tool(description = "Get server health, uptime, build provenance, and filesystem locations")]
fn health(&self) -> Result<CallToolResult, rmcp::ErrorData> {
let loc = crate::filesystem::FsLocation::current();
let val = serde_json::json!({
"status": "ok",
"uptime_secs": now_secs().saturating_sub(self.uptime_start),
"running": true,
"version": crate::build_info::VERSION,
"git_sha": crate::build_info::GIT_SHA,
"build_date": crate::build_info::BUILD_DATE,
"server_root": loc.server_root,
"server_exe_dir": loc.server_exe_dir,
});
Ok(ok(val))
}
#[tool(description = "Echo a message back with a server timestamp")]
fn echo(
&self,
Parameters(EchoInput { message }): Parameters<EchoInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(ok(serde_json::json!({
"message": message,
"timestamp": now_secs(),
})))
}
#[tool(
description = "List managed cron jobs. Defaults to jobs targeting the current machine only; pass local_only=false to see all machines."
)]
fn list_cron_jobs(
&self,
Parameters(params): Parameters<LocalOnlyParam>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let query = cron_jobs::CronJobListQuery {
local_only: Some(params.local_only.unwrap_or(true)),
};
Ok(ok(cron_jobs::svc_list(&self.store, &self.handlers, &query)))
}
#[tool(description = "Get a cron job by ID")]
fn get_cron_job(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match cron_jobs::svc_get(&self.store, &self.handlers, &id) {
Ok(resp) => ok(resp),
Err(error) => err(error),
})
}
#[tool(
description = "Create a new cron job. The schedule cron expression is evaluated in the host's local system timezone (the OS crontab timezone), not UTC."
)]
fn create_cron_job(
&self,
Parameters(req): Parameters<CreateRequest>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(
match cron_jobs::svc_create(&self.store, &self.handlers, req) {
Ok(resp) => ok(resp),
Err(error) => err(error),
},
)
}
#[tool(
description = "Update fields of an existing cron job. A new schedule cron expression is evaluated in the host's local system timezone (the OS crontab timezone), not UTC."
)]
fn update_cron_job(
&self,
Parameters(input): Parameters<UpdateInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let req = UpdateRequest {
schedule: input.schedule,
handler: input.handler,
metadata: input.metadata,
machines: input.machines,
enabled: input.enabled,
};
Ok(
match cron_jobs::svc_update(&self.store, &self.handlers, &input.id, req) {
Ok(resp) => ok(resp),
Err(error) => err(error),
},
)
}
#[tool(description = "Delete a cron job by ID")]
fn delete_cron_job(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(
match cron_jobs::svc_delete(&self.store, &self.handlers, &id) {
Ok(resp) => ok(resp),
Err(error) => err(error),
},
)
}
#[tool(
description = "Manually trigger a cron job outside its schedule, recording last_manual_trigger_at"
)]
fn trigger_cron_job(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match cron_jobs::svc_trigger(&self.store, &id) {
Ok(job) => ok(job),
Err(error) => err(error),
})
}
#[tool(
description = "List managed routines (agent-driven jobs). Defaults to routines targeting the current machine only; pass local_only=false to see all machines."
)]
fn list_routines(
&self,
Parameters(params): Parameters<LocalOnlyParam>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let query = routines::RoutineListQuery {
local_only: Some(params.local_only.unwrap_or(true)),
..Default::default()
};
Ok(ok(routines::svc_list(&self.routines, &query)))
}
#[tool(description = "Get a routine by ID")]
fn get_routine(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match routines::svc_get(&self.routines, &id) {
Ok(resp) => ok(resp),
Err(error) => err(error),
})
}
#[tool(
description = "Create a new routine (agent-driven job). The `schedule` cron expression is interpreted in the local system timezone of the host running the daemon, NOT UTC. The response includes a `timezone` field and a `schedule_description` annotated with that timezone — verify them to confirm the firing time."
)]
fn create_routine(
&self,
Parameters(req): Parameters<CreateRoutineRequest>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match routines::svc_create(&self.routines, req) {
Ok(resp) => ok(resp),
Err(error) => err(error),
})
}
#[tool(
description = "Update fields of an existing routine. The `schedule` cron expression is interpreted in the local system timezone of the host running the daemon, NOT UTC. The response includes a `timezone` field and a `schedule_description` annotated with that timezone — verify them to confirm the firing time."
)]
fn update_routine(
&self,
Parameters(input): Parameters<UpdateRoutineInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let req = UpdateRoutineRequest {
schedule: input.schedule,
title: input.title,
agent: input.agent,
prompt: input.prompt,
repositories: input.repositories,
machines: input.machines,
enabled: input.enabled,
ttl_secs: input.ttl_secs,
max_runtime_secs: input.max_runtime_secs,
};
Ok(match routines::svc_update(&self.routines, &input.id, req) {
Ok(resp) => ok(resp),
Err(error) => err(error),
})
}
#[tool(description = "Delete a routine by ID")]
fn delete_routine(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match routines::svc_delete(&self.routines, &id) {
Ok(resp) => ok(resp),
Err(error) => err(error),
})
}
#[tool(
description = "Manually trigger a routine outside its schedule, recording last_manual_trigger_at"
)]
fn trigger_routine(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match routines::svc_trigger(&self.routines, &id) {
Ok(routine) => ok(routine),
Err(error) => err(error),
})
}
#[tool(
description = "Trigger cleanup of finished, expired routine run workbenches now instead of waiting for the hourly sweep. Returns the number of workbenches removed."
)]
fn cleanup_workbenches(&self) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(ok(routines::svc_cleanup(&self.routines)))
}
#[tool(description = "List the available agent registry keys a routine can launch")]
fn list_agents(&self) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(ok(routines::available_agents()))
}
#[tool(description = "Get a cron job's log file contents by ID")]
fn cron_job_logs(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match cron_jobs::svc_logs(&self.store, &id) {
Ok(logs) => ok(serde_json::json!({ "logs": logs })),
Err(error) => err(error),
})
}
#[tool(description = "Get a routine's newest run log by ID")]
fn routine_logs(
&self,
Parameters(IdInput { id }): Parameters<IdInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(match routines::svc_logs(&self.routines, &id) {
Ok(logs) => ok(serde_json::json!({ "logs": logs })),
Err(error) => err(error),
})
}
#[tool(
description = "Get the global routine lock status. Returns `shared` (committed .lock file), `local` (gitignored .local.lock), and `locked` (either is present)."
)]
fn get_lock_status(&self) -> Result<CallToolResult, rmcp::ErrorData> {
Ok(ok(crate::global_lock::lock_status()))
}
#[tool(
description = "Globally pause all routines by creating a lock sentinel. Use scope=\"shared\" for a committed .lock (shared via git) or scope=\"local\" for a gitignored .local.lock (machine-local). Individual routine enabled states are not modified."
)]
fn lock_routines(
&self,
Parameters(LockRoutinesInput { scope }): Parameters<LockRoutinesInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let lock_scope = match scope.as_str() {
"shared" => crate::global_lock::LockScope::Shared,
"local" => crate::global_lock::LockScope::Local,
other => {
return Ok(err(format!(
"unknown scope {other:?}; use \"shared\" or \"local\""
)))
}
};
if let Err(io_err) = crate::global_lock::set_lock(lock_scope, true) {
return Ok(err(format!("failed to create lock sentinel: {io_err}")));
}
if let Err(sync_err) = crate::sync::routines::sync_routines_to_crontab(&self.routines) {
log::warn!("crontab sync after lock failed: {sync_err}");
}
Ok(ok(crate::global_lock::lock_status()))
}
#[tool(
description = "Resume all routines by removing a lock sentinel. Use scope=\"shared\" to remove the committed .lock, scope=\"local\" to remove the gitignored .local.lock, or scope=\"all\" to remove both."
)]
fn unlock_routines(
&self,
Parameters(UnlockRoutinesInput { scope }): Parameters<UnlockRoutinesInput>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let scopes: Vec<crate::global_lock::LockScope> = match scope.as_str() {
"shared" => vec![crate::global_lock::LockScope::Shared],
"local" => vec![crate::global_lock::LockScope::Local],
"all" => vec![
crate::global_lock::LockScope::Shared,
crate::global_lock::LockScope::Local,
],
other => {
return Ok(err(format!(
"unknown scope {other:?}; use \"shared\", \"local\", or \"all\""
)))
}
};
for scope_item in scopes {
if let Err(io_err) = crate::global_lock::set_lock(scope_item, false) {
return Ok(err(format!("failed to remove lock sentinel: {io_err}")));
}
}
if let Err(sync_err) = crate::sync::routines::sync_routines_to_crontab(&self.routines) {
log::warn!("crontab sync after unlock failed: {sync_err}");
}
Ok(ok(crate::global_lock::lock_status()))
}
#[tool(
description = "Stop the running server gracefully. Mirrors the POST /api/v1/shutdown route and `moadim stop`."
)]
fn shutdown(&self) -> Result<CallToolResult, rmcp::ErrorData> {
log::info!("shutdown requested via MCP");
self.shutdown.notify_one();
Ok(ok(serde_json::json!({ "status": "shutting down" })))
}
#[tool(
description = "Restart the server: stop it and start a fresh instance. Mirrors the POST /api/v1/restart route and `moadim restart`."
)]
fn restart(&self) -> Result<CallToolResult, rmcp::ErrorData> {
log::info!("restart requested via MCP");
Ok(crate::cli::spawn_restart().map_or_else(err, |helper_pid| {
ok(serde_json::json!({ "status": "restarting", "helper_pid": helper_pid }))
}))
}
}
#[cfg(test)]
#[path = "mcp_tests.rs"]
mod mcp_tests;