use async_trait::async_trait;
use serde_json::{Value, json};
use trusty_mcp::{Request, Response, error_codes};
pub mod project_dispatch;
pub mod proxy_dispatch;
pub mod session_dispatch;
pub mod tools;
pub use tools::{TOOL_CATALOG, tool_catalog};
pub const SERVER_NAME: &str = "trusty-mpm";
pub const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
#[async_trait]
pub trait OrchestratorBackend: Send + Sync {
async fn session_list(&self) -> Result<Value, String>;
async fn session_status(&self, session_id: &str) -> Result<Value, String>;
async fn agent_delegate(
&self,
session_id: &str,
agent: &str,
task: &str,
tier: Option<&str>,
) -> Result<Value, String>;
async fn memory_protect(
&self,
session_id: &str,
used_tokens: u64,
window_tokens: u64,
) -> Result<Value, String>;
async fn circuit_breaker_status(&self, agent: Option<&str>) -> Result<Value, String>;
async fn hook_event(
&self,
session_id: &str,
event: &str,
payload: Value,
) -> Result<Value, String>;
async fn list_recent_errors(&self, limit: u64) -> Result<Value, String>;
async fn preview_bug_report(&self, fingerprint: &str) -> Result<Value, String>;
async fn report_bug(&self, fingerprint: &str, confirm: bool) -> Result<Value, String>;
async fn disk_survey(
&self,
project: Option<&str>,
budget_seconds: Option<u64>,
group_by: Option<&str>,
) -> Result<Value, String>;
async fn secrets_get_ref(&self, project: &str, key: &str) -> Result<Value, String>;
async fn session_new(
&self,
repo_url: &str,
git_ref: &str,
task: &str,
name_hint: Option<&str>,
runtime: Option<&str>,
ephemeral: Option<bool>,
) -> Result<Value, String>;
async fn session_stop(&self, session_id: &str) -> Result<Value, String>;
async fn session_resume(&self, session_id: &str) -> Result<Value, String>;
async fn session_decommission(&self, session_id: &str) -> Result<Value, String>;
async fn session_delete(&self, session_id: &str, force: bool) -> Result<Value, String>;
async fn session_delete_records(&self, session_ids: &[String]) -> Result<Value, String>;
async fn session_activity(&self, session_id: &str, lines: u32) -> Result<Value, String>;
async fn session_send(&self, session_id: &str, text: &str) -> Result<Value, String>;
#[allow(clippy::too_many_arguments)]
async fn session_context_catchup(
&self,
project_dir: &str,
session_id: Option<&str>,
tmux_window: Option<&str>,
tmux_session_created: Option<i64>,
all_projects: bool,
full: bool,
sessions_offset: usize,
) -> Result<Value, String>;
#[allow(clippy::too_many_arguments)]
async fn session_context_pause(
&self,
project_dir: &str,
session_id: Option<&str>,
summary: &str,
completed: Vec<String>,
in_progress: Vec<String>,
next_steps: Vec<String>,
tmux_window: Option<&str>,
prune_worktrees: bool,
) -> Result<Value, String>;
async fn session_decommission_ephemeral(&self) -> Result<Value, String>;
async fn session_prune(
&self,
state: &str,
dry_run: bool,
include_active: bool,
) -> Result<Value, String>;
async fn console_metrics(&self) -> Result<Value, String>;
async fn supervisor_status(&self) -> Result<Value, String>;
async fn auto_resume_set(&self, enabled: bool) -> Result<Value, String>;
async fn config_read(&self) -> Result<Value, String>;
#[allow(clippy::too_many_arguments)]
async fn config_write(
&self,
workspace_root_template: Option<&str>,
auto_resume: Option<bool>,
default_model: Option<&str>,
project_name: Option<&str>,
github_config_dir: Option<&str>,
github_token_env: Option<&str>,
github_account: Option<&str>,
github_host: Option<&str>,
commit_name: Option<&str>,
commit_email: Option<&str>,
untracked_sync_patterns: Option<Vec<String>>,
untracked_sync_enabled: Option<bool>,
) -> Result<Value, String>;
async fn project_list(&self) -> Result<Value, String>;
#[allow(clippy::too_many_arguments)]
async fn project_register(
&self,
name: &str,
repo_url: &str,
default_branch: Option<&str>,
stack_hint: Option<&str>,
tags: Option<Vec<String>>,
description: Option<&str>,
gh_user: Option<&str>,
gh_account: Option<&str>,
) -> Result<Value, String>;
async fn project_get(&self, name: &str) -> Result<Value, String>;
async fn project_resolve(&self, query: &str) -> Result<Value, String>;
async fn session_proxy_focus(
&self,
conversation_key: &str,
session_id: &str,
) -> Result<Value, String>;
async fn session_proxy_unfocus(&self, conversation_key: &str) -> Result<Value, String>;
async fn session_proxy_message(
&self,
conversation_key: &str,
text: &str,
) -> Result<Value, String>;
async fn session_proxy_summary(&self, conversation_key: &str) -> Result<Value, String>;
}
pub async fn dispatch<B: OrchestratorBackend>(backend: &B, req: Request) -> Response {
let id = req.id.clone();
match req.method.as_str() {
"initialize" => Response::ok(
id,
trusty_mcp::initialize_response(SERVER_NAME, SERVER_VERSION, None),
),
"tools/list" => Response::ok(id, json!({ "tools": tool_catalog() })),
"tools/call" => dispatch_tool_call(backend, id, req.params).await,
"ping" => Response::ok(id, json!({})),
_ if id.is_none() => Response::suppressed(),
other => Response::err(
id,
error_codes::METHOD_NOT_FOUND,
format!("unknown method: {other}"),
),
}
}
async fn dispatch_tool_call<B: OrchestratorBackend>(
backend: &B,
id: Option<Value>,
params: Option<Value>,
) -> Response {
let params = params.unwrap_or(Value::Null);
let name = match params.get("name").and_then(Value::as_str) {
Some(n) => n,
None => {
return Response::err(
id,
error_codes::INVALID_PARAMS,
"tools/call requires a `name` field",
);
}
};
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let result = match name {
"session_list" => backend.session_list().await,
"session_status" => match required_str(&args, "session_id") {
Ok(sid) => backend.session_status(&sid).await,
Err(e) => Err(e),
},
"agent_delegate" => {
match (
required_str(&args, "session_id"),
required_str(&args, "agent"),
required_str(&args, "task"),
) {
(Ok(sid), Ok(agent), Ok(task)) => {
let tier = args.get("tier").and_then(Value::as_str);
backend.agent_delegate(&sid, &agent, &task, tier).await
}
(Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(e),
}
}
"memory_protect" => {
match (
required_str(&args, "session_id"),
required_u64(&args, "used_tokens"),
required_u64(&args, "window_tokens"),
) {
(Ok(sid), Ok(used), Ok(window)) => backend.memory_protect(&sid, used, window).await,
(Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(e),
}
}
"circuit_breaker_status" => {
let agent = args.get("agent").and_then(Value::as_str);
backend.circuit_breaker_status(agent).await
}
"hook_event" => {
match (
required_str(&args, "session_id"),
required_str(&args, "event"),
) {
(Ok(sid), Ok(event)) => {
let payload = args.get("payload").cloned().unwrap_or(Value::Null);
backend.hook_event(&sid, &event, payload).await
}
(Err(e), _) | (_, Err(e)) => Err(e),
}
}
"list_recent_errors" => {
let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20);
backend.list_recent_errors(limit).await
}
"preview_bug_report" => match required_str(&args, "fingerprint") {
Ok(fp) => backend.preview_bug_report(&fp).await,
Err(e) => Err(e),
},
"report_bug" => match required_str(&args, "fingerprint") {
Ok(fp) => {
let confirm = args
.get("confirm")
.and_then(Value::as_bool)
.unwrap_or(false);
backend.report_bug(&fp, confirm).await
}
Err(e) => Err(e),
},
"disk_survey" => {
let project = args.get("project").and_then(Value::as_str);
let budget_seconds = args.get("budget_seconds").and_then(Value::as_u64);
let group_by = args.get("group_by").and_then(Value::as_str);
backend.disk_survey(project, budget_seconds, group_by).await
}
"secrets_get_ref" => match (required_str(&args, "project"), required_str(&args, "key")) {
(Ok(project), Ok(key)) => backend.secrets_get_ref(&project, &key).await,
(Err(e), _) | (_, Err(e)) => Err(e),
},
"console_metrics" => backend.console_metrics().await,
"supervisor_status" => backend.supervisor_status().await,
"auto_resume_set" => match args.get("enabled").and_then(Value::as_bool) {
Some(enabled) => backend.auto_resume_set(enabled).await,
None => Err("missing required boolean argument: `enabled`".to_string()),
},
"config_read" => backend.config_read().await,
"config_write" => {
let template = args.get("workspace_root_template").and_then(Value::as_str);
let auto_resume = args.get("auto_resume").and_then(Value::as_bool);
let default_model = args.get("default_model").and_then(Value::as_str);
let project_name = args.get("project_name").and_then(Value::as_str);
let github_config_dir = args.get("github_config_dir").and_then(Value::as_str);
let github_token_env = args.get("github_token_env").and_then(Value::as_str);
let github_account = args.get("github_account").and_then(Value::as_str);
let github_host = args.get("github_host").and_then(Value::as_str);
let commit_name = args.get("commit_name").and_then(Value::as_str);
let commit_email = args.get("commit_email").and_then(Value::as_str);
let untracked_sync_patterns = args.get("untracked_sync_patterns").and_then(|v| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|p| p.as_str().map(str::to_string))
.collect::<Vec<String>>()
})
});
let untracked_sync_enabled =
args.get("untracked_sync_enabled").and_then(Value::as_bool);
backend
.config_write(
template,
auto_resume,
default_model,
project_name,
github_config_dir,
github_token_env,
github_account,
github_host,
commit_name,
commit_email,
untracked_sync_patterns,
untracked_sync_enabled,
)
.await
}
other => match project_dispatch::try_dispatch(backend, other, &args).await {
Some(result) => result,
None => match session_dispatch::try_dispatch(backend, other, &args).await {
Some(result) => result,
None => match proxy_dispatch::try_dispatch(backend, other, &args).await {
Some(result) => result,
None => Err(format!("unknown tool: {other}")),
},
},
},
};
match result {
Ok(value) => Response::ok(
id,
json!({
"content": [{ "type": "text", "text": value.to_string() }],
"isError": false,
}),
),
Err(message) => Response::ok(
id,
json!({
"content": [{ "type": "text", "text": message }],
"isError": true,
}),
),
}
}
pub(crate) fn required_str(args: &Value, key: &str) -> Result<String, String> {
args.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| format!("missing required string argument: `{key}`"))
}
pub(crate) fn required_str_array(args: &Value, key: &str) -> Result<Vec<String>, String> {
let items = args
.get(key)
.and_then(Value::as_array)
.ok_or_else(|| format!("missing required array argument: `{key}`"))?;
if items.is_empty() {
return Err(format!("`{key}` must name at least one session"));
}
items
.iter()
.map(|v| {
v.as_str()
.map(str::to_string)
.ok_or_else(|| format!("`{key}` must contain only strings"))
})
.collect()
}
fn required_u64(args: &Value, key: &str) -> Result<u64, String> {
args.get(key)
.and_then(Value::as_u64)
.ok_or_else(|| format!("missing required integer argument: `{key}`"))
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;