use std::collections::BTreeMap;
use serde_json::{Map, Value};
use crate::claude::manifest::{MARKETPLACE_MANIFEST, PLUGIN_MANIFEST, PluginSpec};
use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity};
use crate::mcp_server::McpServerDef;
use crate::mcp_servers::{self, NAME_INVALID, NAME_RULE, Parsed};
use crate::tree::TreeSource;
use crate::visibility::Visibility;
const MCP_JSON: &str = ".mcp.json";
pub(crate) struct ImportedServer {
pub name: String,
pub source_path: String,
pub def: McpServerDef,
}
#[derive(Default)]
pub(crate) struct Imported {
pub servers: Vec<ImportedServer>,
pub diagnostics: Vec<(String, SchemaDiagnostic)>,
}
struct Declared<'a> {
file: String,
prefix: Vec<PathSegment>,
config: &'a Value,
}
pub(crate) fn import(source: &impl TreeSource, spec: &PluginSpec) -> Imported {
let mut out = Imported::default();
let mut documents: Vec<(String, Value)> = Vec::new();
if let Some(value) = read_json(source, MCP_JSON, &mut out) {
documents.push((MCP_JSON.to_owned(), value));
}
let manifest_value = source
.read(PLUGIN_MANIFEST)
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.and_then(|manifest| manifest.get("mcpServers").cloned());
match manifest_value {
Some(value) => {
let entries = match value {
Value::Array(entries) => entries,
other => vec![other],
};
for (index, entry) in entries.into_iter().enumerate() {
match entry {
Value::Object(_) => documents.push((PLUGIN_MANIFEST.to_owned(), entry)),
Value::String(reference) => {
documents.extend(manifest_file(source, &reference, index, &mut out));
}
other => out.warn(
PLUGIN_MANIFEST,
"mcp.manifest-entry-unsupported",
vec![key("mcpServers")],
format!("`mcpServers` entry {other} is not a map or a path to a JSON file, so it was left out"),
),
}
}
}
None if spec.mcp => out.warn(
MARKETPLACE_MANIFEST,
"mcp.manifest-entry-unsupported",
Vec::new(),
"the marketplace entry declares `mcpServers`, which are not imported; declare them in the plugin's `.mcp.json`".into(),
),
None => {}
}
let mut declared: BTreeMap<String, Declared<'_>> = BTreeMap::new();
for (file, document) in &documents {
let Some((prefix, servers)) = server_map(document) else {
out.warn(
file,
"mcp.no-servers",
Vec::new(),
format!("`{file}` holds no map of MCP servers, so nothing was imported from it"),
);
continue;
};
for (name, config) in servers {
let mut prefix = prefix.clone();
prefix.push(key(name));
declared.insert(
name.clone(),
Declared {
file: file.clone(),
prefix,
config,
},
);
}
}
for (name, declared) in declared {
if !mcp_servers::is_server_name(&name) {
out.warn(
&declared.file,
NAME_INVALID,
declared.prefix,
format!(
"MCP server `{name}` was not imported: its name becomes a directory, so it may use only {NAME_RULE}"
),
);
continue;
}
let shadow = format!("mcp/{name}");
if source.is_dir(&shadow) {
out.warn(
&declared.file,
"mcp.shadowed-by-authored",
declared.prefix,
format!("MCP server `{name}` was not imported: the plugin also ships `{shadow}/`, which is used instead"),
);
continue;
}
if let Some(def) = translate(&name, &declared, &mut out) {
out.servers.push(ImportedServer {
name,
source_path: declared.file,
def,
});
}
}
out
}
impl Imported {
fn warn(&mut self, file: &str, code: &str, path: Vec<PathSegment>, message: String) {
self.diagnostics.push((
file.to_owned(),
SchemaDiagnostic::new(code, Severity::Warning, path, message),
));
}
}
fn key(name: &str) -> PathSegment {
PathSegment::Key(name.to_owned())
}
fn read_json(source: &impl TreeSource, file: &str, out: &mut Imported) -> Option<Value> {
let bytes = source.read(file)?;
match serde_json::from_slice(&bytes) {
Ok(value) => Some(value),
Err(error) => {
out.warn(
file,
"mcp.invalid-json",
Vec::new(),
format!("`{file}` is not valid JSON ({error}), so nothing was imported from it"),
);
None
}
}
}
fn manifest_file(
source: &impl TreeSource,
reference: &str,
index: usize,
out: &mut Imported,
) -> Option<(String, Value)> {
let path = reference
.strip_prefix("./")
.unwrap_or(reference)
.trim_end_matches('/');
let local = !reference.contains("://")
&& !path.is_empty()
&& !path.starts_with('/')
&& !path.contains('\\')
&& path.split('/').all(|part| !matches!(part, "" | "." | ".."));
if !local || !path.ends_with(".json") {
out.warn(
PLUGIN_MANIFEST,
"mcp.manifest-entry-unsupported",
vec![key("mcpServers"), PathSegment::Index(index)],
format!("`mcpServers` entry `{reference}` is not a JSON file inside the plugin (a `.mcpb` bundle or a URL cannot be imported), so it was left out"),
);
return None;
}
if !source.is_file(path) {
out.warn(
PLUGIN_MANIFEST,
"mcp.manifest-entry-unsupported",
vec![key("mcpServers"), PathSegment::Index(index)],
format!("`mcpServers` entry `{reference}` names a file the plugin does not contain, so it was left out"),
);
return None;
}
read_json(source, path, out).map(|value| (path.to_owned(), value))
}
fn server_map(document: &Value) -> Option<(Vec<PathSegment>, &Map<String, Value>)> {
let object = document.as_object()?;
match object.get("mcpServers") {
Some(Value::Object(servers)) => Some((vec![key("mcpServers")], servers)),
_ => Some((Vec::new(), object)),
}
}
fn translate(name: &str, declared: &Declared<'_>, out: &mut Imported) -> Option<McpServerDef> {
let Parsed { transport, notes } = mcp_servers::parse(name, declared.config);
for note in notes {
let mut path = declared.prefix.clone();
path.extend(note.field.as_deref().map(key));
out.warn(&declared.file, note.code, path, note.message);
}
Some(McpServerDef {
name: None,
transport: transport?,
visibility: Visibility::Local,
targets: Vec::new(),
})
}