agentgear 0.1.0

Install and self-heal the plugin your Rust binary ships into Claude Code and 24 other coding agents, via a derive macro.
Documentation
//! Shared json-`mcpServers` renderer/reconciler for the json family
//! (gemini/cursor/cline/devin). opencode + codex render bespoke bodies. Every
//! write goes through [`confedit::json_edit`], so it is atomic and merge-safe:
//! only our own server keys are touched, the user's survive.

use std::path::Path;

use serde_json::{Map, Value};

use super::BackendState;
use super::confedit::{json_edit, json_obj_at, json_prune_obj, json_remove};
use crate::components::{McpKind, McpServer};
use crate::error::{Error, Result};
use crate::host::Outcome;

#[derive(Clone, Copy)]
pub(crate) enum StdioShape {
    /// `{command, args, env}` — gemini, cline, devin.
    Plain,
    /// `{type:"stdio", command, args, env}` — cursor.
    Typed,
}

/// The remote (http/sse) dialect. Harnesses agree on the stdio body far more than
/// on the remote one, so the two axes vary independently.
#[derive(Clone, Copy)]
pub(crate) enum RemoteShape {
    /// `{type:"http"|"sse", url, headers:{}}` — the majority dialect.
    TypeUrlHeaders,
    /// Same keys, but the streamable-HTTP discriminator value is `streamableHttp`
    /// — cline's literal-match schema, where a `type:"http"` entry voids the whole
    /// `mcpServers` object (user servers included).
    StreamableHttpValue,
    /// The antigravity family: the only remote form is `{serverUrl}` (SSE; a
    /// `headers` key may ride along once the IR carries any). The schema is
    /// `additionalProperties:false` and refuses `type`/`url` outright — one bad
    /// entry voids the whole file — and http has no landing at all (skipped).
    ServerUrlSseOnly,
    /// `{url, transport:"http"|"sse"}` — kimi + devin. Both key transport on a
    /// `transport` field and never read `type` (kimi strips it, devin ignores
    /// it), so the majority shape silently loads sse over the wrong transport.
    TransportKeyed,
    /// Key presence picks the transport: `{httpUrl}` = http, `{url}` = sse —
    /// qwen-code (`type` is never read, so a `url`-keyed http server would
    /// silently load over SSE).
    HttpUrlKeyed,
    /// `{url, headers:{}}` with no discriminator at all — zed, whose one remote
    /// transport is streamable HTTP. SSE has no faithful landing (zed would dial
    /// the URL as streamable HTTP against an SSE endpoint), so it is skipped.
    UrlHeadersHttpOnly,
    /// The majority `{type:"http", url, headers:{}}` body, http only —
    /// vscode-copilot, whose parser collapses `type:"sse"` to http and whose own
    /// writer then rewrites the stored key (permanent probe churn), so sse is
    /// skipped rather than written with a silently wrong transport.
    TypeUrlHeadersHttpOnly,
    /// `{type, url}` with no flat `headers` key — jetbrains-copilot, whose bundled
    /// MCP SDK reads outgoing fetch headers ONLY from `requestInit.headers` (a
    /// top-level `headers` is silently ignored). The IR carries no headers yet;
    /// when it does, this dialect nests them under `requestInit.headers`.
    TypeUrl,
    /// `{url, headers:{}, transport:"streamable-http"|"sse"}` — openclaw's own
    /// canonical form. openclaw accepts the majority `{type,url,headers}` dialect
    /// too, but `doctor --fix`/`mcp set` rewrite it into this shape on disk, which
    /// defeats a whole-object probe forever (`docs/research/verify-openclaw.md`
    /// #2/#4). Rendering it directly makes a post-canonicalization probe read
    /// `Healthy` instead of churning.
    UrlHeadersTransport,
}

#[derive(Clone, Copy)]
pub(crate) struct ServerShape {
    pub(crate) stdio: StdioShape,
    pub(crate) remote: RemoteShape,
}

impl ServerShape {
    pub(crate) const fn plain() -> Self {
        Self { stdio: StdioShape::Plain, remote: RemoteShape::TypeUrlHeaders }
    }

    pub(crate) const fn typed() -> Self {
        Self { stdio: StdioShape::Typed, remote: RemoteShape::TypeUrlHeaders }
    }

    pub(crate) const fn with_remote(mut self, remote: RemoteShape) -> Self {
        self.remote = remote;
        self
    }
}

/// The servers this shape actually writes: portable AND renderable. reconcile,
/// probe, and remove all key off this one filter so ownership can never drift
/// between them (§chokepoint).
fn writable(servers: &[McpServer], shape: ServerShape) -> Vec<&McpServer> {
    servers.iter().filter(|s| s.is_portable() && render_server(s, shape).is_some()).collect()
}

/// Render one server body per `shape`; `None` when the dialect has no faithful
/// landing for the server's kind. A `None` server is skipped exactly like a
/// non-portable one — never written, never owned, never removed — so rendering is
/// the single source of truth for what a dialect supports.
pub(crate) fn render_server(server: &McpServer, shape: ServerShape) -> Option<Value> {
    match &server.kind {
        McpKind::Stdio => {
            let mut obj = Map::new();
            if matches!(shape.stdio, StdioShape::Typed) {
                obj.insert("type".into(), Value::from("stdio"));
            }
            obj.insert("command".into(), Value::from(server.command.clone()));
            obj.insert("args".into(), Value::from(server.args.clone()));
            obj.insert("env".into(), env_value(server));
            Some(Value::Object(obj))
        }
        McpKind::Http { url } => remote(shape.remote, "http", url),
        McpKind::Sse { url } => remote(shape.remote, "sse", url),
    }
}

fn remote(shape: RemoteShape, kind: &str, url: &str) -> Option<Value> {
    let mut obj = Map::new();
    match shape {
        RemoteShape::TypeUrlHeadersHttpOnly if kind != "http" => return None,
        RemoteShape::TypeUrlHeaders | RemoteShape::StreamableHttpValue | RemoteShape::TypeUrlHeadersHttpOnly => {
            let type_value = match shape {
                RemoteShape::StreamableHttpValue if kind == "http" => "streamableHttp",
                _ => kind,
            };
            obj.insert("type".into(), Value::from(type_value));
            obj.insert("url".into(), Value::from(url));
            obj.insert("headers".into(), Value::Object(Map::new()));
        }
        RemoteShape::ServerUrlSseOnly => {
            if kind != "sse" {
                return None;
            }
            obj.insert("serverUrl".into(), Value::from(url));
        }
        RemoteShape::TransportKeyed => {
            obj.insert("url".into(), Value::from(url));
            obj.insert("transport".into(), Value::from(kind));
        }
        RemoteShape::HttpUrlKeyed => {
            let key = if kind == "http" { "httpUrl" } else { "url" };
            obj.insert(key.into(), Value::from(url));
        }
        RemoteShape::UrlHeadersHttpOnly => {
            if kind != "http" {
                return None;
            }
            obj.insert("url".into(), Value::from(url));
            obj.insert("headers".into(), Value::Object(Map::new()));
        }
        RemoteShape::TypeUrl => {
            obj.insert("type".into(), Value::from(kind));
            obj.insert("url".into(), Value::from(url));
        }
        RemoteShape::UrlHeadersTransport => {
            obj.insert("url".into(), Value::from(url));
            obj.insert("headers".into(), Value::Object(Map::new()));
            let transport = if kind == "http" { "streamable-http" } else { kind };
            obj.insert("transport".into(), Value::from(transport));
        }
    }
    Some(Value::Object(obj))
}

fn env_value(server: &McpServer) -> Value {
    let env: Map<String, Value> = server.env.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect();
    Value::Object(env)
}

/// Insert/update exactly our servers under `key_path`, leaving others. `NoOp` when
/// the file already matches (no write).
pub(crate) fn reconcile(path: &Path, key_path: &[&str], servers: &[McpServer], shape: ServerShape) -> Result<Outcome> {
    let changed = json_edit(path, |root| {
        let obj = json_obj_at(root, key_path);
        // Skip non-portable/unrenderable servers here so no json backend can forget
        // to (§chokepoint).
        for server in writable(servers, shape) {
            if let Some(body) = render_server(server, shape) {
                obj.insert(server.name.clone(), body);
            }
        }
        Ok(())
    })?;
    Ok(if changed { Outcome::Installed } else { Outcome::NoOp })
}

/// `Absent` if none of our servers are present; `Healthy` if all present and
/// matching; `NeedsRepair` otherwise. (The json family has no disable flag we set.)
pub(crate) fn probe(path: &Path, key_path: &[&str], servers: &[McpServer], shape: ServerShape) -> Result<BackendState> {
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BackendState::Absent),
        Err(source) => return Err(Error::Io { context: format!("reading {}", path.display()), source }),
    };
    let root: Value =
        serde_json::from_slice(&bytes).map_err(|e| Error::Config { path: path.display().to_string(), detail: e.to_string() })?;
    let obj = navigate(&root, key_path);

    // Only writable servers are ever written, so only they define ownership. With
    // none to install, this renderer has nothing that could be "gone" -> Healthy,
    // never Absent (an Absent here would make self_heal drop a present marker).
    let ours = writable(servers, shape);
    if ours.is_empty() {
        return Ok(BackendState::Healthy);
    }

    let mut present = 0usize;
    let mut matching = 0usize;
    for server in &ours {
        if let Some(existing) = obj.and_then(|o| o.get(&server.name)) {
            present += 1;
            if render_server(server, shape).is_some_and(|r| r == *existing) {
                matching += 1;
            }
        }
    }
    Ok(if present == 0 {
        BackendState::Absent
    } else if matching == ours.len() {
        BackendState::Healthy
    } else {
        BackendState::NeedsRepair
    })
}

/// The composition-aware form of [`probe`]: `None` when this shape writes nothing
/// (no portable+renderable server), so the mcp surface contributes no verdict to
/// [`super::report::compose`]; otherwise `Some(probe(...))`. Keeps
/// `compose([Some(mcp)]) == mcp` for a plugin whose only surface is mcp.
pub(crate) fn probe_surface(path: &Path, key_path: &[&str], servers: &[McpServer], shape: ServerShape) -> Result<Option<BackendState>> {
    if writable(servers, shape).is_empty() {
        return Ok(None);
    }
    Ok(Some(probe(path, key_path, servers, shape)?))
}

/// Remove exactly our server keys under `key_path`, leaving others. Ownership is
/// the same writable filter reconcile uses, so a server we declared but never
/// wrote (non-portable, or unsupported by this dialect) can never shadow-delete a
/// same-named user entry. The container object and then the file follow our last key
/// out when our own removal is what emptied them, so an uninstall leaves nothing of
/// ours behind; a container the user had empty before us is untouched.
pub(crate) fn remove(path: &Path, key_path: &[&str], servers: &[McpServer], shape: ServerShape) -> Result<Outcome> {
    if !path.exists() {
        return Ok(Outcome::NoOp);
    }
    let changed = json_remove(path, |root| {
        json_prune_obj(root, key_path, |obj| {
            for server in writable(servers, shape) {
                obj.remove(&server.name);
            }
            Ok(())
        })
        .map(|_| ())
    })?;
    Ok(if changed { Outcome::Removed } else { Outcome::NoOp })
}

fn navigate<'a>(root: &'a Value, key_path: &[&str]) -> Option<&'a Map<String, Value>> {
    let mut cur = root;
    for key in key_path {
        cur = cur.get(key)?;
    }
    cur.as_object()
}

#[cfg(test)]
#[path = "../../tests/unit/mcpjson.rs"]
mod mcpjson_tests;