use std::collections::{BTreeMap, BTreeSet};
use crate::config::{Config, HostEntry, McpServer, McpTransport, Memory};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMcp {
pub name: String,
pub kind: ResolvedKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedKind {
Stdio {
command: String,
args: Vec<String>,
env: BTreeMap<String, String>,
},
Remote {
url: String,
transport: McpTransport,
},
}
pub const MEMORY_MCP_NAME: &str = "icm";
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ResolveError {
#[error("memory: server_host '{0}' has no entry in the `host:` table")]
MemoryUnknownServerHost(String),
#[error("mcp '{name}': stdio transport requires a `command`")]
StdioMissingCommand { name: String },
#[error("mcp '{name}': {transport} transport requires a `url`")]
RemoteMissingUrl { name: String, transport: String },
}
pub fn resolve_mcps(
config: &Config,
active_tags: &BTreeSet<String>,
) -> Result<Vec<ResolvedMcp>, ResolveError> {
let mut out = Vec::new();
for m in &config.mcp {
if !m.tags.iter().any(|t| active_tags.contains(t)) {
continue;
}
out.push(resolve_static(m)?);
}
if let Some(mem) = config.features.as_ref().and_then(|f| f.memory.as_ref())
&& mem.tags.iter().any(|t| active_tags.contains(t))
{
out.push(resolve_memory(mem, &config.host)?);
}
Ok(out)
}
fn resolve_static(m: &McpServer) -> Result<ResolvedMcp, ResolveError> {
let kind = match m.transport {
McpTransport::Stdio => stdio_kind(m)?,
McpTransport::Http | McpTransport::Sse => remote_kind(m, m.transport)?,
};
Ok(ResolvedMcp {
name: m.name.clone(),
kind,
})
}
fn resolve_memory(
mem: &Memory,
hosts: &BTreeMap<String, HostEntry>,
) -> Result<ResolvedMcp, ResolveError> {
let entry = hosts
.get(&mem.server_host)
.ok_or_else(|| ResolveError::MemoryUnknownServerHost(mem.server_host.clone()))?;
Ok(ResolvedMcp {
name: MEMORY_MCP_NAME.to_string(),
kind: ResolvedKind::Remote {
url: format!("http://{}:{}/mcp", entry.addr, mem.port),
transport: McpTransport::Http,
},
})
}
fn stdio_kind(m: &McpServer) -> Result<ResolvedKind, ResolveError> {
let command = m
.command
.clone()
.ok_or_else(|| ResolveError::StdioMissingCommand {
name: m.name.clone(),
})?;
Ok(ResolvedKind::Stdio {
command,
args: m.args.clone(),
env: m.env.clone(),
})
}
fn remote_kind(m: &McpServer, transport: McpTransport) -> Result<ResolvedKind, ResolveError> {
let url = m
.url
.clone()
.ok_or_else(|| ResolveError::RemoteMissingUrl {
name: m.name.clone(),
transport: format!("{transport:?}").to_lowercase(),
})?;
Ok(ResolvedKind::Remote { url, transport })
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::config::{Features, McpServer, Memory};
fn tags(ts: &[&str]) -> BTreeSet<String> {
ts.iter().map(|s| (*s).to_string()).collect()
}
fn base_config() -> Config {
Config {
host: BTreeMap::from([(
"still".to_string(),
HostEntry {
addr: "still.local".to_string(),
},
)]),
..Config::default()
}
}
fn stdio_server(name: &str, tags: &[&str], command: &str) -> McpServer {
McpServer {
name: name.into(),
tags: tags.iter().map(|s| (*s).into()).collect(),
transport: McpTransport::Stdio,
command: Some(command.into()),
args: vec![],
env: BTreeMap::new(),
url: None,
}
}
fn memory() -> Memory {
Memory {
server_host: "still".into(),
port: 7878,
tags: vec!["network-home".into()],
default_topics: vec![],
}
}
#[test]
fn selects_only_servers_with_intersecting_tags() {
let mut cfg = base_config();
cfg.mcp = vec![
stdio_server("playwright", &["user-ranger"], "npx"),
stdio_server("tolaria", &["host-still"], "tolaria-mcp"),
];
let resolved = resolve_mcps(&cfg, &tags(&["user-ranger"])).unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "playwright");
}
#[test]
fn stdio_server_resolves_to_command() {
let mut cfg = base_config();
let mut s = stdio_server("playwright", &["t"], "npx");
s.args = vec!["-y".into(), "@playwright/mcp@latest".into()];
s.env = BTreeMap::from([("HEADLESS".to_string(), "1".to_string())]);
cfg.mcp = vec![s];
let resolved = resolve_mcps(&cfg, &tags(&["t"])).unwrap();
match &resolved[0].kind {
ResolvedKind::Stdio { command, args, env } => {
assert_eq!(command, "npx");
assert_eq!(args, &["-y", "@playwright/mcp@latest"]);
assert_eq!(env.get("HEADLESS").map(String::as_str), Some("1"));
}
other => panic!("expected stdio, got {other:?}"),
}
}
#[test]
fn memory_always_resolves_to_network_client() {
let mut cfg = base_config();
cfg.features = Some(Features {
memory: Some(memory()),
});
for active_tags in [tags(&["network-home"]), tags(&["network-home"])] {
let resolved = resolve_mcps(&cfg, &active_tags).unwrap();
assert_eq!(resolved[0].name, MEMORY_MCP_NAME);
match &resolved[0].kind {
ResolvedKind::Remote { url, transport } => {
assert_eq!(url, "http://still.local:7878/mcp");
assert_eq!(*transport, McpTransport::Http);
}
other => panic!("expected remote client, got {other:?}"),
}
}
}
#[test]
fn memory_not_selected_when_tags_inactive() {
let mut cfg = base_config();
cfg.features = Some(Features {
memory: Some(memory()),
});
let resolved = resolve_mcps(&cfg, &tags(&["unrelated"])).unwrap();
assert!(resolved.is_empty());
}
#[test]
fn memory_with_unknown_host_errors() {
let mut cfg = base_config();
cfg.host.clear();
cfg.features = Some(Features {
memory: Some(memory()),
});
let err = resolve_mcps(&cfg, &tags(&["network-home"])).unwrap_err();
assert_eq!(err, ResolveError::MemoryUnknownServerHost("still".into()));
}
#[test]
fn stdio_without_command_errors() {
let mut cfg = base_config();
let mut s = stdio_server("broken", &["t"], "x");
s.command = None;
cfg.mcp = vec![s];
let err = resolve_mcps(&cfg, &tags(&["t"])).unwrap_err();
assert_eq!(
err,
ResolveError::StdioMissingCommand {
name: "broken".into()
}
);
}
#[test]
fn http_server_resolves_to_remote() {
let mut cfg = base_config();
let mut s = stdio_server("ctx7", &["t"], "x");
s.transport = McpTransport::Http;
s.command = None;
s.url = Some("https://ctx7.example/mcp".into());
cfg.mcp = vec![s];
let resolved = resolve_mcps(&cfg, &tags(&["t"])).unwrap();
match &resolved[0].kind {
ResolvedKind::Remote { url, transport } => {
assert_eq!(url, "https://ctx7.example/mcp");
assert_eq!(*transport, McpTransport::Http);
}
other => panic!("expected remote, got {other:?}"),
}
}
mod props {
use super::*;
use proptest::prelude::*;
fn arb_server(idx: usize) -> impl Strategy<Value = McpServer> {
prop::collection::vec("[a-z]{1,4}", 0..4).prop_map(move |ts| McpServer {
name: format!("srv-{idx}"),
tags: ts,
transport: McpTransport::Stdio,
command: Some("echo".into()),
args: vec![],
env: BTreeMap::new(),
url: None,
})
}
fn arb_config_and_tags() -> impl Strategy<Value = (Config, BTreeSet<String>)> {
let servers = (0usize..5).prop_flat_map(|n| (0..n).map(arb_server).collect::<Vec<_>>());
let active = prop::collection::btree_set("[a-z]{1,4}", 0..6);
(servers, active).prop_map(|(mcp, active)| {
let mut cfg = base_config();
cfg.mcp = mcp;
(cfg, active)
})
}
proptest! {
#[test]
fn output_count_bounded_by_inputs((cfg, active) in arb_config_and_tags()) {
let resolved = resolve_mcps(&cfg, &active).expect("valid servers resolve");
prop_assert!(resolved.len() <= cfg.mcp.len());
}
#[test]
fn every_selected_server_has_active_tag((cfg, active) in arb_config_and_tags()) {
let resolved = resolve_mcps(&cfg, &active).expect("valid servers resolve");
for r in &resolved {
let src = cfg
.mcp
.iter()
.find(|m| m.name == r.name)
.expect("resolved name maps to a declared server");
prop_assert!(src.tags.iter().any(|t| active.contains(t)));
}
}
#[test]
fn resolution_is_deterministic((cfg, active) in arb_config_and_tags()) {
let a = resolve_mcps(&cfg, &active).expect("resolve");
let b = resolve_mcps(&cfg, &active).expect("resolve");
prop_assert_eq!(a, b);
}
}
}
}