use rmcp::handler::server::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{schemars, tool, tool_router};
use super::client::{format_json, TmaiHttpClient};
#[derive(Debug)]
pub struct TmaiMcpServer {
pub tool_router: ToolRouter<Self>,
client: TmaiHttpClient,
}
impl TmaiMcpServer {
pub fn new(client: TmaiHttpClient) -> Self {
Self {
tool_router: Self::tool_router(),
client,
}
}
fn validate_project_scope(&self, agent_id: &str) -> Option<String> {
use super::client::ValidateError;
let project_git_dir = match self.client.resolve_git_common_dir() {
Ok(dir) => dir,
Err(_) => return None, };
let path = format!("/agents/{}/validate-project", agent_id);
let body = serde_json::json!({ "project": project_git_dir });
match self.client.post_with_error_body(&path, &body) {
Ok(_) => None, Err(ValidateError::HttpError { status: 403 }) => Some(format!(
"Error: agent {} belongs to a different project. \
Cross-project operations are not allowed.",
agent_id
)),
Err(ValidateError::HttpError { status: 404 }) => {
None }
Err(_) => None, }
}
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct EmptyParams {}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ListAgentsParams {
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub phase: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AgentIdParams {
pub id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SendTextParams {
pub id: String,
pub text: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SendPromptParams {
pub id: String,
pub prompt: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SendKeyParams {
pub id: String,
pub key: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SelectChoiceParams {
pub id: String,
pub index: u32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RepoParams {
#[serde(default)]
pub repo: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BranchParams {
pub branch: String,
#[serde(default)]
pub repo: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct PrNumberParams {
pub pr_number: u32,
#[serde(default)]
pub repo: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SpawnAgentParams {
pub directory: String,
#[serde(default)]
pub prompt: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SpawnWorktreeParams {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub issue_number: Option<u64>,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub base_branch: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct WorktreeDeleteParams {
pub worktree_name: String,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub force: bool,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct DispatchIssueParams {
pub issue_number: u64,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub base_branch: Option<String>,
#[serde(default)]
pub additional_instructions: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SetOrchestratorParams {
pub id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SpawnOrchestratorParams {
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub additional_instructions: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct MergePrParams {
pub pr_number: u32,
#[serde(default = "default_merge_method")]
pub method: String,
#[serde(default = "default_true")]
pub delete_branch: bool,
#[serde(default)]
pub delete_worktree: bool,
#[serde(default)]
pub worktree_name: Option<String>,
#[serde(default)]
pub repo: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ReviewPrParams {
pub pr_number: u32,
pub action: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub repo: Option<String>,
}
fn default_merge_method() -> String {
"squash".to_string()
}
fn default_true() -> bool {
true
}
#[tool_router]
impl TmaiMcpServer {
#[tool(
description = "List monitored AI agents (scoped to current project by default). Filter by phase: working, blocked, idle, offline."
)]
fn list_agents(&self, Parameters(p): Parameters<ListAgentsParams>) -> String {
let project = match &p.project {
Some(proj) if proj == "*" => None,
Some(proj) => Some(proj.clone()),
None => self.client.resolve_git_common_dir().ok(),
};
let path = match &project {
Some(proj) => format!("/agents?project={}", encode(proj)),
None => "/agents".to_string(),
};
if p.phase.is_none() {
return self.client.get_json_or_error(&path);
}
match self.client.get::<serde_json::Value>(&path) {
Ok(agents) => {
let lower = p.phase.as_ref().unwrap().to_ascii_lowercase();
if let Some(arr) = agents.as_array() {
let filtered: Vec<&serde_json::Value> = arr
.iter()
.filter(|a| {
a.get("phase")
.and_then(|v| v.as_str())
.is_some_and(|p| p.to_ascii_lowercase() == lower)
})
.collect();
return format_json(&serde_json::Value::Array(
filtered.into_iter().cloned().collect(),
));
}
format_json(&agents)
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Get detailed info about a specific agent")]
fn get_agent(&self, Parameters(p): Parameters<AgentIdParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
match self.client.get::<serde_json::Value>("/agents") {
Ok(data) => {
if let Some(agents) = data.as_array() {
if let Some(agent) = agents.iter().find(|a| {
a.get("id").and_then(|v| v.as_str()) == Some(&p.id)
|| a.get("pane_id").and_then(|v| v.as_str()) == Some(&p.id)
|| a.get("target").and_then(|v| v.as_str()) == Some(&p.id)
|| a.get("pty_session_id").and_then(|v| v.as_str()) == Some(&p.id)
}) {
return format_json(agent);
}
}
format!("Agent not found: {}", p.id)
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Get the terminal output of an agent")]
fn get_agent_output(&self, Parameters(p): Parameters<AgentIdParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
self.client
.get_text_or_error(&format!("/agents/{}/output", p.id))
}
#[tool(description = "Get the conversation transcript of an agent")]
fn get_transcript(&self, Parameters(p): Parameters<AgentIdParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
self.client
.get_json_or_error(&format!("/agents/{}/transcript", p.id))
}
#[tool(description = "Approve a pending permission request for an agent")]
fn approve(&self, Parameters(p): Parameters<AgentIdParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
self.client.post_ok_or_error(
&format!("/agents/{}/approve", p.id),
&serde_json::json!({}),
format!("Approved agent {}", p.id),
)
}
#[tool(description = "Send text input to an agent")]
fn send_text(&self, Parameters(p): Parameters<SendTextParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
self.client.post_ok_or_error(
&format!("/agents/{}/input", p.id),
&serde_json::json!({"text": p.text}),
format!("Sent text to agent {}", p.id),
)
}
#[tool(description = "Send a prompt to an agent (queues if busy, delivers when idle)")]
fn send_prompt(&self, Parameters(p): Parameters<SendPromptParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
match self.client.post::<serde_json::Value>(
&format!("/agents/{}/prompt", p.id),
&serde_json::json!({"prompt": p.prompt}),
) {
Ok(data) => {
let action = data
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let queue_size = data.get("queue_size").and_then(|v| v.as_u64()).unwrap_or(0);
match action {
"sent" => format!("Prompt sent to agent {} (idle)", p.id),
"sent_restart" => {
format!("Prompt sent to agent {} (restarting from stopped)", p.id)
}
"queued" => format!(
"Prompt queued for agent {} (queue position: {})",
p.id, queue_size
),
_ => format!("Prompt action '{}' for agent {}", action, p.id),
}
}
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Send a special key to an agent")]
fn send_key(&self, Parameters(p): Parameters<SendKeyParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
self.client.post_ok_or_error(
&format!("/agents/{}/key", p.id),
&serde_json::json!({"key": p.key}),
format!("Sent key '{}' to agent {}", p.key, p.id),
)
}
#[tool(description = "Select a choice for an agent's question")]
fn select_choice(&self, Parameters(p): Parameters<SelectChoiceParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
self.client.post_ok_or_error(
&format!("/agents/{}/select", p.id),
&serde_json::json!({"index": p.index}),
format!("Selected choice {} for agent {}", p.index, p.id),
)
}
#[tool(description = "Kill (terminate) an agent by ID")]
fn kill_agent(&self, Parameters(p): Parameters<AgentIdParams>) -> String {
if let Some(err) = self.validate_project_scope(&p.id) {
return err;
}
self.client.delete_ok_or_error(
&format!("/agents/{}", p.id),
format!("Killed agent {}", p.id),
)
}
#[tool(description = "List all agent teams")]
fn list_teams(&self, Parameters(_): Parameters<EmptyParams>) -> String {
self.client.get_json_or_error("/teams")
}
#[tool(description = "List all git worktrees")]
fn list_worktrees(&self, Parameters(_): Parameters<EmptyParams>) -> String {
self.client.get_json_or_error("/worktrees")
}
#[tool(description = "Spawn a new AI agent in a directory")]
fn spawn_agent(&self, Parameters(p): Parameters<SpawnAgentParams>) -> String {
let mut body = serde_json::json!({"directory": p.directory});
if let Some(prompt) = &p.prompt {
body["initial_prompt"] = serde_json::json!(prompt);
}
self.client.post_json_or_error("/spawn", &body)
}
#[tool(description = "Create a worktree and spawn an agent in it")]
fn spawn_worktree(&self, Parameters(p): Parameters<SpawnWorktreeParams>) -> String {
if p.name.is_none() && p.issue_number.is_none() {
return "Error: either 'name' or 'issue_number' must be provided".to_string();
}
let cwd = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
let mut body = serde_json::json!({"cwd": cwd});
if let Some(name) = &p.name {
body["name"] = serde_json::json!(name);
}
if let Some(issue_number) = p.issue_number {
body["issue_number"] = serde_json::json!(issue_number);
}
if let Some(base) = &p.base_branch {
body["base_branch"] = serde_json::json!(base);
}
if let Some(prompt) = &p.prompt {
body["initial_prompt"] = serde_json::json!(prompt);
}
self.client.post_json_or_error("/spawn/worktree", &body)
}
#[tool(description = "Spawn an orchestrator agent with workflow settings from config")]
fn spawn_orchestrator(&self, Parameters(p): Parameters<SpawnOrchestratorParams>) -> String {
let mut body = serde_json::json!({});
if let Some(ref cwd) = p.cwd {
body["cwd"] = serde_json::json!(cwd);
}
if let Some(ref extra) = p.additional_instructions {
body["additional_instructions"] = serde_json::json!(extra);
}
self.client.post_json_or_error("/orchestrator/spawn", &body)
}
#[tool(description = "Mark an existing agent as orchestrator (e.g. after /resume recovery)")]
fn set_orchestrator(&self, Parameters(p): Parameters<SetOrchestratorParams>) -> String {
self.client.post_ok_or_error(
&format!("/agents/{}/set-orchestrator", p.id),
&serde_json::json!({}),
format!("Agent {} is now the orchestrator", p.id),
)
}
#[tool(
description = "Dispatch a GitHub issue: fetch issue, create worktree, spawn agent with issue context"
)]
fn dispatch_issue(&self, Parameters(p): Parameters<DispatchIssueParams>) -> String {
let cwd = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
let mut body = serde_json::json!({
"cwd": cwd,
"issue_number": p.issue_number,
});
if let Some(base) = &p.base_branch {
body["base_branch"] = serde_json::json!(base);
}
if let Some(extra) = &p.additional_instructions {
body["additional_instructions"] = serde_json::json!(extra);
}
self.client.post_json_or_error("/spawn/worktree", &body)
}
#[tool(description = "Delete a git worktree")]
fn delete_worktree(&self, Parameters(p): Parameters<WorktreeDeleteParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
let repo_path = if repo.ends_with(".git") {
repo.clone()
} else {
format!("{}/.git", repo)
};
self.client.post_ok_or_error(
"/worktrees/delete",
&serde_json::json!({
"repo_path": repo_path,
"worktree_name": p.worktree_name,
"force": p.force
}),
format!("Deleted worktree: {}", p.worktree_name),
)
}
#[tool(description = "List open pull requests")]
fn list_prs(&self, Parameters(p): Parameters<RepoParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client
.get_json_or_error(&format!("/github/prs?repo={}", encode(&repo)))
}
#[tool(description = "List open issues")]
fn list_issues(&self, Parameters(p): Parameters<RepoParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client
.get_json_or_error(&format!("/github/issues?repo={}", encode(&repo)))
}
#[tool(description = "Get CI check results for a branch")]
fn get_ci_status(&self, Parameters(p): Parameters<BranchParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client.get_json_or_error(&format!(
"/github/checks?branch={}&repo={}",
encode(&p.branch),
encode(&repo)
))
}
#[tool(description = "Get PR comments and reviews")]
fn get_pr_comments(&self, Parameters(p): Parameters<PrNumberParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client.get_json_or_error(&format!(
"/github/pr/comments?pr={}&repo={}",
p.pr_number,
encode(&repo)
))
}
#[tool(description = "Get PR merge status")]
fn get_pr_merge_status(&self, Parameters(p): Parameters<PrNumberParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client.get_json_or_error(&format!(
"/github/pr/merge-status?pr={}&repo={}",
p.pr_number,
encode(&repo)
))
}
#[tool(description = "Get CI failure log for a branch")]
fn get_ci_failure_log(&self, Parameters(p): Parameters<BranchParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client.get_text_or_error(&format!(
"/github/ci/failure-log?branch={}&repo={}",
encode(&p.branch),
encode(&repo)
))
}
#[tool(description = "Rerun failed CI checks")]
fn rerun_ci(&self, Parameters(p): Parameters<BranchParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client.post_ok_or_error(
"/github/ci/rerun",
&serde_json::json!({"branch": p.branch, "repo": repo}),
format!("Rerunning failed checks for branch: {}", p.branch),
)
}
#[tool(description = "Review a pull request — approve, request changes, or post a comment")]
fn review_pr(&self, Parameters(p): Parameters<ReviewPrParams>) -> String {
if !["approve", "request_changes", "comment"].contains(&p.action.as_str()) {
return format!(
"Error: invalid action '{}' — must be approve, request_changes, or comment",
p.action
);
}
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
let body = serde_json::json!({
"repo": repo,
"pr_number": p.pr_number,
"action": p.action,
"body": p.body,
});
self.client.post_json_or_error("/github/pr/review", &body)
}
#[tool(
description = "Merge a pull request (checks CI first, then squash/merge/rebase with optional branch and worktree cleanup)"
)]
fn merge_pr(&self, Parameters(p): Parameters<MergePrParams>) -> String {
if !["squash", "merge", "rebase"].contains(&p.method.as_str()) {
return format!(
"Error: invalid merge method '{}' — must be squash, merge, or rebase",
p.method
);
}
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
let body = serde_json::json!({
"repo": repo,
"pr_number": p.pr_number,
"method": p.method,
"delete_branch": p.delete_branch,
"delete_worktree": p.delete_worktree,
"worktree_name": p.worktree_name,
});
self.client.post_json_or_error("/github/pr/merge", &body)
}
#[tool(description = "List git branches")]
fn list_branches(&self, Parameters(p): Parameters<RepoParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client
.get_json_or_error(&format!("/git/branches?repo={}", encode(&repo)))
}
#[tool(description = "Get diff stats for a branch")]
fn git_diff_stat(&self, Parameters(p): Parameters<BranchParams>) -> String {
let repo = match self.client.resolve_repo(&p.repo) {
Ok(r) => r,
Err(e) => return format!("Error: {e}"),
};
self.client.get_json_or_error(&format!(
"/git/diff-stat?branch={}&repo={}",
encode(&p.branch),
encode(&repo)
))
}
}
fn encode(s: &str) -> String {
s.replace('%', "%25")
.replace(' ', "%20")
.replace('#', "%23")
.replace('&', "%26")
.replace('=', "%3D")
.replace('+', "%2B")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_agents_params_empty() {
let json = serde_json::json!({});
let p: ListAgentsParams = serde_json::from_value(json).unwrap();
assert!(p.project.is_none());
}
#[test]
fn list_agents_params_with_project() {
let json = serde_json::json!({"project": "/home/user/project-a/.git"});
let p: ListAgentsParams = serde_json::from_value(json).unwrap();
assert_eq!(p.project.as_deref(), Some("/home/user/project-a/.git"));
}
#[test]
fn list_agents_params_wildcard() {
let json = serde_json::json!({"project": "*"});
let p: ListAgentsParams = serde_json::from_value(json).unwrap();
assert_eq!(p.project.as_deref(), Some("*"));
}
#[test]
fn spawn_orchestrator_params_empty() {
let json = serde_json::json!({});
let p: SpawnOrchestratorParams = serde_json::from_value(json).unwrap();
assert!(p.cwd.is_none());
assert!(p.additional_instructions.is_none());
}
#[test]
fn spawn_orchestrator_params_all_fields() {
let json = serde_json::json!({
"cwd": "/tmp/project",
"additional_instructions": "Focus on issue #42"
});
let p: SpawnOrchestratorParams = serde_json::from_value(json).unwrap();
assert_eq!(p.cwd.as_deref(), Some("/tmp/project"));
assert_eq!(
p.additional_instructions.as_deref(),
Some("Focus on issue #42")
);
}
#[test]
fn dispatch_issue_params_required_only() {
let json = serde_json::json!({"issue_number": 42});
let p: DispatchIssueParams = serde_json::from_value(json).unwrap();
assert_eq!(p.issue_number, 42);
assert!(p.repo.is_none());
assert!(p.base_branch.is_none());
assert!(p.additional_instructions.is_none());
}
#[test]
fn dispatch_issue_params_all_fields() {
let json = serde_json::json!({
"issue_number": 99,
"repo": "/tmp/repo",
"base_branch": "develop",
"additional_instructions": "Use TDD"
});
let p: DispatchIssueParams = serde_json::from_value(json).unwrap();
assert_eq!(p.issue_number, 99);
assert_eq!(p.repo.as_deref(), Some("/tmp/repo"));
assert_eq!(p.base_branch.as_deref(), Some("develop"));
assert_eq!(p.additional_instructions.as_deref(), Some("Use TDD"));
}
#[test]
fn dispatch_issue_params_missing_issue_number_fails() {
let json = serde_json::json!({"repo": "/tmp/repo"});
assert!(serde_json::from_value::<DispatchIssueParams>(json).is_err());
}
#[test]
fn merge_pr_params_defaults() {
let json = serde_json::json!({"pr_number": 42});
let p: MergePrParams = serde_json::from_value(json).unwrap();
assert_eq!(p.pr_number, 42);
assert_eq!(p.method, "squash");
assert!(p.delete_branch);
assert!(!p.delete_worktree);
assert!(p.worktree_name.is_none());
assert!(p.repo.is_none());
}
#[test]
fn merge_pr_params_all_fields() {
let json = serde_json::json!({
"pr_number": 99,
"method": "rebase",
"delete_branch": false,
"delete_worktree": false,
"worktree_name": "99-feat-something",
"repo": "/tmp/repo"
});
let p: MergePrParams = serde_json::from_value(json).unwrap();
assert_eq!(p.pr_number, 99);
assert_eq!(p.method, "rebase");
assert!(!p.delete_branch);
assert!(!p.delete_worktree);
assert_eq!(p.worktree_name.as_deref(), Some("99-feat-something"));
assert_eq!(p.repo.as_deref(), Some("/tmp/repo"));
}
#[test]
fn merge_pr_params_missing_pr_number_fails() {
let json = serde_json::json!({"method": "squash"});
assert!(serde_json::from_value::<MergePrParams>(json).is_err());
}
}