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 {
Plain,
Typed,
}
#[derive(Clone, Copy)]
pub(crate) enum RemoteShape {
TypeUrlHeaders,
StreamableHttpValue,
ServerUrlSseOnly,
TransportKeyed,
HttpUrlKeyed,
UrlHeadersHttpOnly,
TypeUrlHeadersHttpOnly,
TypeUrl,
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
}
}
fn writable(servers: &[McpServer], shape: ServerShape) -> Vec<&McpServer> {
servers.iter().filter(|s| s.is_portable() && render_server(s, shape).is_some()).collect()
}
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)
}
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);
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 })
}
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);
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
})
}
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)?))
}
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;