use rmcp::handler::server::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{schemars, tool, tool_router};
use super::client::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,
}
}
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct EmptyParams {}
#[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 SpawnOrchestratorParams {
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub additional_instructions: Option<String>,
}
#[tool_router]
impl TmaiMcpServer {
#[tool(description = "List all monitored AI agents with their status")]
fn list_agents(&self, Parameters(_): Parameters<EmptyParams>) -> String {
match self.client.get::<serde_json::Value>("/agents") {
Ok(agents) => 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 {
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 {
match self.client.get_text(&format!("/agents/{}/output", p.id)) {
Ok(text) => text,
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Get the conversation transcript of an agent")]
fn get_transcript(&self, Parameters(p): Parameters<AgentIdParams>) -> String {
match self
.client
.get::<serde_json::Value>(&format!("/agents/{}/transcript", p.id))
{
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Approve a pending permission request for an agent")]
fn approve(&self, Parameters(p): Parameters<AgentIdParams>) -> String {
match self
.client
.post_ok(&format!("/agents/{}/approve", p.id), &serde_json::json!({}))
{
Ok(()) => format!("Approved agent {}", p.id),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Send text input to an agent")]
fn send_text(&self, Parameters(p): Parameters<SendTextParams>) -> String {
match self.client.post_ok(
&format!("/agents/{}/input", p.id),
&serde_json::json!({"text": p.text}),
) {
Ok(()) => format!("Sent text to agent {}", p.id),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Send a prompt to an agent (queues if busy, delivers when idle)")]
fn send_prompt(&self, Parameters(p): Parameters<SendPromptParams>) -> String {
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 {
match self.client.post_ok(
&format!("/agents/{}/key", p.id),
&serde_json::json!({"key": p.key}),
) {
Ok(()) => format!("Sent key '{}' to agent {}", p.key, p.id),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "Select a choice for an agent's question")]
fn select_choice(&self, Parameters(p): Parameters<SelectChoiceParams>) -> String {
match self.client.post_ok(
&format!("/agents/{}/select", p.id),
&serde_json::json!({"index": p.index}),
) {
Ok(()) => format!("Selected choice {} for agent {}", p.index, p.id),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "List all agent teams")]
fn list_teams(&self, Parameters(_): Parameters<EmptyParams>) -> String {
match self.client.get::<serde_json::Value>("/teams") {
Ok(teams) => format_json(&teams),
Err(e) => format!("Error: {e}"),
}
}
#[tool(description = "List all git worktrees")]
fn list_worktrees(&self, Parameters(_): Parameters<EmptyParams>) -> String {
match self.client.get::<serde_json::Value>("/worktrees") {
Ok(wt) => format_json(&wt),
Err(e) => format!("Error: {e}"),
}
}
#[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);
}
match self.client.post::<serde_json::Value>("/spawn", &body) {
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
#[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);
}
match self
.client
.post::<serde_json::Value>("/spawn/worktree", &body)
{
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
#[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);
}
match self
.client
.post::<serde_json::Value>("/orchestrator/spawn", &body)
{
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
#[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);
}
match self
.client
.post::<serde_json::Value>("/spawn/worktree", &body)
{
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
#[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)
};
match self.client.post_ok(
"/worktrees/delete",
&serde_json::json!({
"repo_path": repo_path,
"worktree_name": p.worktree_name,
"force": p.force
}),
) {
Ok(()) => format!("Deleted worktree: {}", p.worktree_name),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self
.client
.get::<serde_json::Value>(&format!("/github/prs?repo={}", encode(&repo)))
{
Ok(prs) => format_json(&prs),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self
.client
.get::<serde_json::Value>(&format!("/github/issues?repo={}", encode(&repo)))
{
Ok(issues) => format_json(&issues),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self.client.get::<serde_json::Value>(&format!(
"/github/checks?branch={}&repo={}",
encode(&p.branch),
encode(&repo)
)) {
Ok(checks) => format_json(&checks),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self.client.get::<serde_json::Value>(&format!(
"/github/pr/comments?pr={}&repo={}",
p.pr_number,
encode(&repo)
)) {
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self.client.get::<serde_json::Value>(&format!(
"/github/pr/merge-status?pr={}&repo={}",
p.pr_number,
encode(&repo)
)) {
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self.client.get_text(&format!(
"/github/ci/failure-log?branch={}&repo={}",
encode(&p.branch),
encode(&repo)
)) {
Ok(log) => log,
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self.client.post_ok(
"/github/ci/rerun",
&serde_json::json!({"branch": p.branch, "repo": repo}),
) {
Ok(()) => format!("Rerunning failed checks for branch: {}", p.branch),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self
.client
.get::<serde_json::Value>(&format!("/git/branches?repo={}", encode(&repo)))
{
Ok(branches) => format_json(&branches),
Err(e) => format!("Error: {e}"),
}
}
#[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}"),
};
match self.client.get::<serde_json::Value>(&format!(
"/git/diff-stat?branch={}&repo={}",
encode(&p.branch),
encode(&repo)
)) {
Ok(data) => format_json(&data),
Err(e) => format!("Error: {e}"),
}
}
}
fn encode(s: &str) -> String {
s.replace('%', "%25")
.replace(' ', "%20")
.replace('#', "%23")
.replace('&', "%26")
.replace('=', "%3D")
.replace('+', "%2B")
}
fn format_json(value: &serde_json::Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[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());
}
}