crabmate 0.4.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
//! `GET` / `PUT` / `POST` **`/user-data/*`**(受保护路由;密钥不落 GET 明文)。

use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use serde::Deserialize;
use serde_json::Value;

use crate::user_data::{
    LlmOverridesFile, McpServerStatusEntry, McpServersFile, McpServersFilePublic,
    McpServersImportResponse, McpServersStatusResponse, SecretsStatusResponse, UserPrefs,
    WebSessionsFile, WorkspaceListEntry,
};
use crate::user_data::{
    append_mcp_json_import, ensure_user_data_tree, list_workspaces, load_llm_overrides,
    load_mcp_servers_with_legacy_import, load_prefs, load_web_sessions, mcp_servers_file_public,
    merge_mcp_commands_from_stored, normalize_mcp_servers_file, save_llm_overrides,
    save_mcp_servers, save_prefs, save_web_sessions, secrets_status, validate_mcp_secret_server_id,
    validate_sessions_value, write_secret_mcp_bearer, write_secret_web_api_bearer,
};
use crate::web::app_state::AppStateHttpCore;

#[derive(Debug, Deserialize)]
pub(crate) struct SecretWriteBody {
    #[serde(default)]
    pub(crate) api_key: Option<String>,
    #[serde(default)]
    pub(crate) token: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct McpRemoteAuthBody {
    /// Bearer token;空字符串清除本机 secret。
    #[serde(default)]
    pub(crate) bearer_token: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct PutSessionsBody {
    #[serde(default)]
    pub(crate) sessions: Value,
    #[serde(default)]
    pub(crate) active_session_id: Option<String>,
}

fn user_data_err(status: StatusCode, msg: impl Into<String>) -> (StatusCode, String) {
    (status, msg.into())
}

fn mcp_status_from_runtime(st: crate::mcp::McpServerRuntimeStatus) -> McpServerStatusEntry {
    McpServerStatusEntry {
        id: st.id,
        name: st.name,
        slug: st.slug,
        enabled: st.enabled,
        connected: st.connected,
        transport: st.transport,
        openai_tool_names: st.openai_tool_names,
        remote_tools: st.remote_tools,
        last_error: st.last_error,
        last_error_kind: st.last_error_kind,
    }
}

pub(crate) async fn get_prefs_handler() -> Json<UserPrefs> {
    let _ = ensure_user_data_tree();
    Json(load_prefs())
}

pub(crate) async fn put_prefs_handler(
    Json(prefs): Json<UserPrefs>,
) -> Result<StatusCode, (StatusCode, String)> {
    save_prefs(&prefs).map_err(|e| user_data_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    Ok(StatusCode::NO_CONTENT)
}

pub(crate) async fn get_llm_overrides_handler() -> Json<LlmOverridesFile> {
    let _ = ensure_user_data_tree();
    Json(load_llm_overrides())
}

pub(crate) async fn put_llm_overrides_handler(
    Json(file): Json<LlmOverridesFile>,
) -> Result<StatusCode, (StatusCode, String)> {
    save_llm_overrides(&file).map_err(|e| user_data_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    Ok(StatusCode::NO_CONTENT)
}

pub(crate) async fn get_secrets_status_handler() -> Json<SecretsStatusResponse> {
    Json(secrets_status())
}

pub(crate) async fn put_secret_web_api_bearer_handler(
    Json(body): Json<SecretWriteBody>,
) -> Result<StatusCode, (StatusCode, String)> {
    let token = body.token.or(body.api_key).unwrap_or_default();
    write_secret_web_api_bearer(&token)
        .map_err(|e| user_data_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    Ok(StatusCode::NO_CONTENT)
}

pub(crate) async fn get_workspaces_handler()
-> Result<Json<Vec<WorkspaceListEntry>>, (StatusCode, String)> {
    list_workspaces()
        .map(Json)
        .map_err(|e| user_data_err(StatusCode::INTERNAL_SERVER_ERROR, e))
}

pub(crate) async fn get_current_sessions_handler(
    State(http): State<AppStateHttpCore>,
) -> Json<WebSessionsFile> {
    let ws = http.effective_workspace_path().await;
    Json(load_web_sessions(&ws))
}

pub(crate) async fn put_current_sessions_handler(
    State(http): State<AppStateHttpCore>,
    Json(body): Json<PutSessionsBody>,
) -> Result<StatusCode, (StatusCode, String)> {
    validate_sessions_value(&body.sessions)
        .map_err(|e| user_data_err(StatusCode::BAD_REQUEST, e))?;
    let ws = http.effective_workspace_path().await;
    let file = WebSessionsFile {
        schema_version: crate::user_data::SCHEMA_VERSION,
        sessions: body.sessions,
        active_session_id: body.active_session_id,
    };
    save_web_sessions(&ws, &file)
        .map_err(|e| user_data_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    Ok(StatusCode::NO_CONTENT)
}

async fn mcp_legacy_import_params(http: &AppStateHttpCore) -> (bool, String, u64) {
    let cfg = http.cfg.read().await;
    (
        cfg.mcp_client.mcp_enabled,
        cfg.mcp_client.mcp_command.clone(),
        cfg.mcp_client.mcp_tool_timeout_secs,
    )
}

pub(crate) async fn get_mcp_servers_handler(
    State(http): State<AppStateHttpCore>,
) -> Json<McpServersFilePublic> {
    let _ = ensure_user_data_tree();
    let (enabled, cmd, timeout) = mcp_legacy_import_params(&http).await;
    let file = load_mcp_servers_with_legacy_import(enabled, &cmd, timeout);
    Json(mcp_servers_file_public(&file))
}

fn decode_mcp_servers_put(value: Value) -> Result<McpServersFile, String> {
    if let Ok(file) = serde_json::from_value::<McpServersFile>(value.clone())
        && !file.servers.is_empty()
    {
        return Ok(file);
    }
    if value.get("mcpServers").is_some() {
        let imported = crate::user_data::mcp_json_import::import_mcp_json_value(&value)?;
        let global_enabled = value
            .get("global_enabled")
            .and_then(|v| v.as_bool())
            .unwrap_or(true);
        let tool_timeout_secs = value
            .get("tool_timeout_secs")
            .and_then(|v| v.as_u64())
            .unwrap_or(60)
            .max(1);
        return Ok(McpServersFile {
            schema_version: crate::user_data::SCHEMA_VERSION,
            global_enabled,
            tool_timeout_secs,
            servers: imported.entries,
            // 经 Web PUT 写入 user-data 后关闭 TOML/`CM_MCP_COMMAND` 一次性导入窗口。
            toml_legacy_imported: true,
        });
    }
    serde_json::from_value::<McpServersFile>(value).map_err(|e| {
        format!(
            "MCP 配置 JSON 无效(须为 CrabMate mcp_servers 或含 mcpServers 的 MCP 配置 JSON): {e}"
        )
    })
}

pub(crate) async fn put_mcp_servers_handler(
    State(_http): State<AppStateHttpCore>,
    Json(body): Json<Value>,
) -> Result<StatusCode, (StatusCode, String)> {
    let file =
        decode_mcp_servers_put(body).map_err(|e| user_data_err(StatusCode::BAD_REQUEST, e))?;
    let file = merge_mcp_commands_from_stored(file);
    let file =
        normalize_mcp_servers_file(file).map_err(|e| user_data_err(StatusCode::BAD_REQUEST, e))?;
    save_mcp_servers(&file).map_err(|e| user_data_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    crate::mcp::clear_mcp_process_cache().await;
    Ok(StatusCode::NO_CONTENT)
}

fn decode_mcp_json_import_body(body: Value) -> Result<Value, String> {
    if let Some(text) = body.as_str() {
        let trimmed = text.trim();
        if trimmed.is_empty() {
            return Err("JSON 为空".to_string());
        }
        return serde_json::from_str(trimmed).map_err(|e| format!("JSON 解析失败: {e}"));
    }
    Ok(body)
}

pub(crate) async fn post_mcp_servers_import_handler(
    Json(body): Json<Value>,
) -> Result<Json<McpServersImportResponse>, (StatusCode, String)> {
    let _ = ensure_user_data_tree();
    let value =
        decode_mcp_json_import_body(body).map_err(|e| user_data_err(StatusCode::BAD_REQUEST, e))?;
    let resp =
        append_mcp_json_import(&value).map_err(|e| user_data_err(StatusCode::BAD_REQUEST, e))?;
    crate::mcp::clear_mcp_process_cache().await;
    Ok(Json(resp))
}

pub(crate) async fn get_mcp_servers_status_handler(
    State(http): State<AppStateHttpCore>,
) -> Json<McpServersStatusResponse> {
    let _ = ensure_user_data_tree();
    let (enabled, cmd, timeout) = mcp_legacy_import_params(&http).await;
    let file = load_mcp_servers_with_legacy_import(enabled, &cmd, timeout);
    let cfg = http.cfg.read().await;
    let resolved = crate::mcp::resolve_mcp_config(&cfg);
    let runtime = crate::mcp::mcp_servers_runtime_status(&resolved).await;
    let servers: Vec<McpServerStatusEntry> =
        runtime.into_iter().map(mcp_status_from_runtime).collect();
    Json(McpServersStatusResponse {
        global_enabled: file.global_enabled,
        tool_timeout_secs: file.tool_timeout_secs,
        servers,
    })
}

pub(crate) async fn put_mcp_server_remote_auth_handler(
    State(http): State<AppStateHttpCore>,
    Path(server_id): Path<String>,
    Json(body): Json<McpRemoteAuthBody>,
) -> Result<StatusCode, (StatusCode, String)> {
    let id = validate_mcp_secret_server_id(&server_id)
        .map_err(|e| user_data_err(StatusCode::BAD_REQUEST, e))?
        .to_string();
    let (enabled, cmd, timeout) = mcp_legacy_import_params(&http).await;
    let file = load_mcp_servers_with_legacy_import(enabled, &cmd, timeout);
    let Some(srv) = file.servers.iter().find(|s| s.id == id) else {
        return Err(user_data_err(StatusCode::NOT_FOUND, "未找到 MCP 服务器"));
    };
    if !srv.has_remote_url() {
        return Err(user_data_err(
            StatusCode::BAD_REQUEST,
            "仅远程(url)MCP 服务器可设置 Bearer",
        ));
    }
    let token = body.bearer_token.unwrap_or_default();
    write_secret_mcp_bearer(&id, &token)
        .map_err(|e| user_data_err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
    crate::mcp::clear_mcp_process_cache().await;
    Ok(StatusCode::NO_CONTENT)
}

pub(crate) async fn post_mcp_server_probe_handler(
    State(http): State<AppStateHttpCore>,
    Path(server_id): Path<String>,
) -> Result<Json<McpServerStatusEntry>, (StatusCode, String)> {
    let (enabled, cmd, timeout) = mcp_legacy_import_params(&http).await;
    let _ = load_mcp_servers_with_legacy_import(enabled, &cmd, timeout);
    let cfg = http.cfg.read().await;
    let resolved = crate::mcp::resolve_mcp_config(&cfg);
    let Some(server) = resolved.servers.iter().find(|s| s.id == server_id) else {
        return Err(user_data_err(StatusCode::NOT_FOUND, "未找到 MCP 服务器"));
    };
    let st = crate::mcp::probe_mcp_server(server).await;
    Ok(Json(mcp_status_from_runtime(st)))
}

pub(crate) async fn post_mcp_servers_probe_all_handler(
    State(http): State<AppStateHttpCore>,
) -> Json<Vec<McpServerStatusEntry>> {
    let (enabled, cmd, timeout) = mcp_legacy_import_params(&http).await;
    let _ = load_mcp_servers_with_legacy_import(enabled, &cmd, timeout);
    let cfg = http.cfg.read().await;
    let resolved = crate::mcp::resolve_mcp_config(&cfg);
    let mut out = Vec::new();
    for server in resolved.servers.iter().filter(|s| s.enabled) {
        let st = crate::mcp::probe_mcp_server(server).await;
        out.push(mcp_status_from_runtime(st));
    }
    Json(out)
}