roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
//! MCP server management API endpoints for the WebUI dashboard.
//!
//! Provides CRUD + status endpoints for managing external MCP server connections.
//!
//! Endpoints:
//! - `GET  /api/mcp/servers`            — list configured servers with live status
//! - `GET  /api/mcp/servers/{name}`     — get a single server's status
//! - `POST /api/mcp/servers/{name}/test` — test connectivity to a server

use axum::{
    Json,
    extract::{Path, State},
    response::IntoResponse,
};
use serde_json::json;

use super::{AppState, JsonError, internal_err, not_found};

/// GET /api/mcp/servers — list all configured servers with status.
pub async fn list_servers(State(state): State<AppState>) -> Result<impl IntoResponse, JsonError> {
    let config = state.config.read().await;
    let mcp_clients = state.mcp_clients.read().await;

    let servers: Vec<serde_json::Value> = config
        .mcp
        .servers
        .iter()
        .map(|s| {
            let conn = mcp_clients.get_connection(&s.name);
            let connected = conn.map(|c| c.is_connected()).unwrap_or(false);
            let tool_count = conn.map(|c| c.available_tools.len()).unwrap_or(0);

            let transport = match &s.spec {
                roboticus_core::config::McpServerSpec::Stdio { command, .. } => {
                    json!({"type": "stdio", "command": command})
                }
                roboticus_core::config::McpServerSpec::Sse { url } => {
                    json!({"type": "sse", "url": url})
                }
            };

            json!({
                "name": s.name,
                "enabled": s.enabled,
                "connected": connected,
                "tool_count": tool_count,
                "transport": transport,
            })
        })
        .collect();

    Ok(Json(json!(servers)))
}

/// GET /api/mcp/servers/{name} — single server status.
pub async fn get_server(
    State(state): State<AppState>,
    Path(name): Path<String>,
) -> Result<impl IntoResponse, JsonError> {
    let config = state.config.read().await;
    let server = config
        .mcp
        .servers
        .iter()
        .find(|s| s.name == name)
        .ok_or_else(|| not_found(format!("MCP server '{name}' not configured")))?;

    let mcp_clients = state.mcp_clients.read().await;
    let conn = mcp_clients.get_connection(&name);
    let connected = conn.map(|c| c.is_connected()).unwrap_or(false);

    let tools: Vec<serde_json::Value> = conn
        .map(|c| {
            c.available_tools
                .iter()
                .map(|t| {
                    json!({
                        "name": t.name,
                        "description": t.description,
                    })
                })
                .collect()
        })
        .unwrap_or_default();

    let transport = match &server.spec {
        roboticus_core::config::McpServerSpec::Stdio { command, args, .. } => {
            json!({"type": "stdio", "command": command, "args": args})
        }
        roboticus_core::config::McpServerSpec::Sse { url } => {
            json!({"type": "sse", "url": url})
        }
    };

    Ok(Json(json!({
        "name": server.name,
        "enabled": server.enabled,
        "connected": connected,
        "transport": transport,
        "tools": tools,
    })))
}

/// POST /api/mcp/servers/{name}/test — test connectivity by attempting a live connection.
pub async fn test_server(
    State(state): State<AppState>,
    Path(name): Path<String>,
) -> Result<impl IntoResponse, JsonError> {
    let server = {
        let config = state.config.read().await;
        config
            .mcp
            .servers
            .iter()
            .find(|s| s.name == name)
            .ok_or_else(|| not_found(format!("MCP server '{name}' not configured")))?
            .clone()
    };

    let start = std::time::Instant::now();
    match roboticus_agent::mcp::client::LiveMcpConnection::connect(&server).await {
        Ok(conn) => {
            let elapsed = start.elapsed();
            Ok(Json(json!({
                "name": name,
                "success": true,
                "server_name": conn.server_name(),
                "server_version": conn.server_version(),
                "tool_count": conn.tools().len(),
                "tools": conn.tools().iter().map(|t| json!({
                    "name": t.name,
                    "description": t.description,
                })).collect::<Vec<_>>(),
                "latency_ms": elapsed.as_millis(),
            })))
        }
        Err(e) => {
            let elapsed = start.elapsed();
            tracing::warn!(name, error = %e, "MCP server connectivity test failed");
            Ok(Json(json!({
                "name": name,
                "success": false,
                "error": internal_err(&e).1,
                "latency_ms": elapsed.as_millis(),
            })))
        }
    }
}