///|
/// Configuration discovery and parsing.
///
/// mcpbox reads the same `mcpServers` blocks the other MCP clients use, so an
/// existing Cursor, Claude or VS Code configuration works without a rewrite.
/// Files are JSONC: `//` and `/* */` comments and trailing commas are accepted
/// and stripped before parsing.
///|
/// One config file mcpbox knows how to read. `key` is the object that holds
/// the server map: `mcpServers` everywhere except VS Code, which uses
/// `servers`.
priv struct Candidate {
path : String
key : String
}
///|
/// The user's home directory, or an empty string when it cannot be found.
fn home_dir() -> String {
match @env.get_env_var("HOME") {
Some(home) if home.length() > 0 => home
_ =>
match @env.get_env_var("USERPROFILE") {
Some(profile) => profile
None => ""
}
}
}
///|
/// Expand a leading `~` into the home directory.
fn expand_home(path : String) -> String {
if path == "~" {
return home_dir()
}
if path.has_prefix("~/") {
return home_dir() + path[1:].to_owned()
}
path
}
///|
/// Resolve an explicitly configured path the way the config layers are
/// resolved: surrounding whitespace is ignorable and a quoted `~/...` expands
/// to the home directory.
fn resolve_config_path(path : String) -> String {
expand_home(path.trim().to_owned())
}
///|
/// The directory holding a config file. Used to anchor relative `cwd` values
/// so a stdio server behaves the same no matter where mcpbox was launched.
fn config_directory(source : String) -> String {
@path.Path(source).dirname().to_string()
}
///|
/// Resolve a stdio `cwd` against the config file that declared it. A leading
/// `~` expands to the home directory; relative paths are anchored at the
/// config file, not at the caller's working directory.
fn resolve_cwd(cwd : String, source : String) -> String {
let trimmed = cwd.trim().to_owned()
if trimmed.length() == 0 {
return ""
}
let expanded = expand_home(trimmed)
if @path.Path(expanded).is_absolute() {
expanded
} else {
@path.Path(config_directory(source))
.join(@path.Path(expanded))
.normalize()
.to_string()
}
}
///|
/// Match one server name against a `config` filter. A filter without wildcards
/// is a substring match; `*` matches any sequence and `?` one character. The
/// wildcard form matches any substring too, using bounded dynamic programming
/// so a pathological pattern cannot backtrack exponentially.
fn server_matches_filter(name : String, filter : String) -> Bool {
if !filter.contains("*") && !filter.contains("?") {
return name.contains(filter)
}
let text = name.to_array()
let pattern = filter.to_array()
let m = pattern.length()
if m == 0 {
return true
}
let mut previous : Array[Bool] = Array::make(m + 1, false)
previous[0] = true
for i in 1..<(m + 1) {
previous[i] = pattern[i - 1] == '*' && previous[i - 1]
}
if previous[m] {
return true
}
for c in text {
let current : Array[Bool] = Array::make(m + 1, false)
current[0] = true
for i in 1..<(m + 1) {
let token = pattern[i - 1]
current[i] = if token == '*' {
current[i - 1] || previous[i]
} else {
previous[i - 1] && (token == '?' || token == c)
}
}
if current[m] {
return true
}
previous = current
}
false
}
///|
/// True when a server matches any of the filters (an empty list matches all).
fn server_matches_filters(name : String, filters : Array[String]) -> Bool {
if filters.length() == 0 {
return true
}
for filter in filters {
if server_matches_filter(name, filter) {
return true
}
}
false
}
///|
/// The XDG config directory, falling back to `~/.config`.
fn config_home() -> String {
match @env.get_env_var("XDG_CONFIG_HOME") {
Some(dir) if dir.length() > 0 => dir
_ => home_dir() + "/.config"
}
}
///|
/// Candidate config files in precedence order: the first definition of a name
/// wins. Project-local files come before user-wide ones, and mcpbox's own
/// files before the ones imported from other clients.
fn config_candidates() -> Array[Candidate] {
let home = home_dir()
let xdg = config_home()
let candidates : Array[Candidate] = []
// An explicit path overrides everything else. Trim it and expand a leading
// `~` so a quoted `--config`-style value works the same as the layers below.
match @env.get_env_var("MCPBOX_CONFIG") {
Some(path) if path.trim().length() > 0 =>
candidates.push({ path: resolve_config_path(path), key: "mcpServers", })
_ => ()
}
for
path in [
"./mcpbox.json", "./.mcpbox.json", "./config/mcpbox.json", "./.mcpbox/config.json",
"./.cursor/mcp.json", "./.vscode/mcp.json",
] {
candidates.push({
path,
key: if path == "./.vscode/mcp.json" {
"servers"
} else {
"mcpServers"
},
})
}
candidates.push({ path: "\{home}/.mcpbox.json", key: "mcpServers", })
candidates.push({ path: "\{xdg}/mcpbox/config.json", key: "mcpServers", })
candidates.push({ path: "\{home}/.cursor/mcp.json", key: "mcpServers", })
candidates.push({ path: "\{home}/.claude.json", key: "mcpServers", })
candidates.push({
path: "\{home}/.codeium/windsurf/mcp_config.json",
key: "mcpServers",
})
candidates.push({
path: "\{home}/.config/Claude/claude_desktop_config.json",
key: "mcpServers",
})
candidates.push({
path: "\{home}/Library/Application Support/Claude/claude_desktop_config.json",
key: "mcpServers",
})
candidates
}
///|
/// Strip `//` and `/* */` comments from JSONC text, leaving string literals
/// untouched.
fn strip_jsonc_comments(text : String) -> String {
let out = StringBuilder(size_hint=text.length())
let chars = text.to_array()
let n = chars.length()
let mut i = 0
let mut in_string = false
while i < n {
let c = chars[i]
if in_string {
out.write_char(c)
if c == '\\' && i + 1 < n {
out.write_char(chars[i + 1])
i += 2
continue
}
if c == '"' {
in_string = false
}
i += 1
continue
}
if c == '"' {
in_string = true
out.write_char(c)
i += 1
continue
}
if c == '/' && i + 1 < n {
if chars[i + 1] == '/' {
i += 2
while i < n && chars[i] != '\n' {
i += 1
}
continue
}
if chars[i + 1] == '*' {
i += 2
while i + 1 < n && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1
}
i += 2
continue
}
}
out.write_char(c)
i += 1
}
out.to_string()
}
///|
/// Drop commas that sit directly before a closing brace or bracket, the other
/// concession JSONC makes to hand-written config.
fn strip_trailing_commas(text : String) -> String {
let out = StringBuilder(size_hint=text.length())
let chars = text.to_array()
let n = chars.length()
let mut i = 0
let mut in_string = false
while i < n {
let c = chars[i]
if in_string {
out.write_char(c)
if c == '\\' && i + 1 < n {
out.write_char(chars[i + 1])
i += 2
continue
}
if c == '"' {
in_string = false
}
i += 1
continue
}
if c == '"' {
in_string = true
out.write_char(c)
i += 1
continue
}
if c == ',' {
let mut j = i + 1
while j < n && is_json_space(chars[j]) {
j += 1
}
if j < n && (chars[j] == '}' || chars[j] == ']') {
i += 1
continue
}
}
out.write_char(c)
i += 1
}
out.to_string()
}
///|
fn is_json_space(c : Char) -> Bool {
c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
///|
/// Parse JSONC text into a `Json` value.
pub fn parse_jsonc(text : String) -> Json raise McpError {
let cleaned = strip_trailing_commas(strip_jsonc_comments(text))
@json.parse(cleaned) catch {
e => raise fail("invalid JSON: \{e.to_string()}")
}
}
///|
/// Expand `${VAR}` and `${VAR:-fallback}` in a string. An unset variable with
/// no fallback expands to the empty string rather than failing, so a missing
/// optional token does not make the whole config unreadable.
pub fn expand_env(text : String) -> String {
let out = StringBuilder(size_hint=text.length())
let chars = text.to_array()
let n = chars.length()
let mut i = 0
while i < n {
let c = chars[i]
if c == '$' && i + 1 < n && chars[i + 1] == '{' {
let inner = StringBuilder()
let mut j = i + 2
while j < n && chars[j] != '}' {
inner.write_char(chars[j])
j += 1
}
if j < n {
let expr = inner.to_string()
let (name, fallback) = match expr.split_once(":-") {
Some((name, fallback)) => (name.to_owned(), Some(fallback.to_owned()))
None => (expr, None)
}
match @env.get_env_var(name) {
Some(value) => out.write_string(value)
None =>
match fallback {
Some(value) => out.write_string(value)
None => ()
}
}
i = j + 1
continue
}
// An unterminated `${` is emitted literally.
out.write_char('$')
i += 1
continue
}
out.write_char(c)
i += 1
}
out.to_string()
}
///|
/// Read a string field, expanding environment references.
fn json_string(obj : Map[String, Json], key : String) -> String? {
match obj.get(key) {
Some(String(value)) => Some(expand_env(value))
_ => None
}
}
///|
/// Read a `Map[String, String]` field, expanding environment references in
/// both keys and values.
fn json_string_map(
obj : Map[String, Json],
key : String,
) -> Map[String, String] {
let out : Map[String, String] = Map([])
match obj.get(key) {
Some(Object(entries)) =>
for entry in entries {
match entry {
(k, String(v)) => out[expand_env(k)] = expand_env(v)
(k, Number(n, ..)) => out[expand_env(k)] = n.to_string()
(k, True) => out[expand_env(k)] = "true"
(k, False) => out[expand_env(k)] = "false"
_ => ()
}
}
_ => ()
}
out
}
///|
/// Read a string array field, expanding environment references.
fn json_string_array(obj : Map[String, Json], key : String) -> Array[String] {
let out : Array[String] = []
match obj.get(key) {
Some(Array(items)) =>
for item in items {
match item {
String(value) => out.push(expand_env(value))
_ => ()
}
}
_ => ()
}
out
}
///|
/// Parse one server definition. Both the mcporter-style `url`/`command`
/// shorthand and the `type`/`transport` form used by VS Code are accepted.
fn parse_server(
name : String,
json : Json,
source : String,
) -> Server raise McpError {
guard json is Object(obj) else {
raise fail("\{source}: server '\{name}' must be an object")
}
let kind = match obj.get("transport") {
Some(String(kind)) => Some(expand_env(kind))
_ =>
match obj.get("type") {
Some(String(kind)) => Some(expand_env(kind))
_ => None
}
}
let url = json_string(obj, "url")
let command = json_string(obj, "command")
// `type`/`transport` disambiguates, but a definition with a `url` and no
// command is unambiguous enough to infer.
let use_http = match kind {
Some(k) => k == "http" || k == "streamable-http" || k == "sse"
None => url is Some(_) && command is None
}
if use_http {
match url {
Some(url) =>
return {
name,
kind: Http({ url, headers: json_string_map(obj, "headers"), }),
note: json_string(obj, "description").unwrap_or(source),
}
None => raise fail("\{source}: server '\{name}' is HTTP but has no 'url'")
}
}
match command {
Some(command) =>
{
name,
kind: Stdio({
command,
args: json_string_array(obj, "args"),
env: json_string_map(obj, "env"),
cwd: match json_string(obj, "cwd") {
Some(cwd) => resolve_cwd(cwd, source)
None => ""
},
}),
note: json_string(obj, "description").unwrap_or(source),
}
None =>
raise fail(
"\{source}: server '\{name}' needs a 'command' (stdio) or a 'url' (HTTP)",
)
}
}
///|
/// Extract the servers from one parsed config document. Returns an empty array
/// when the file has no server block, so an unrelated `mcp.json` is skipped
/// rather than reported as broken.
pub fn servers_from_document(
json : Json,
key : String,
source : String,
) -> Array[Server] raise McpError {
guard json is Object(root) else {
raise fail("\{source}: the config root must be a JSON object")
}
match root.get(key) {
None => []
Some(Object(servers)) => {
let out : Array[Server] = []
for entry in servers {
let (name, definition) = entry
out.push(parse_server(name, definition, source))
}
out
}
Some(_) => raise fail("\{source}: '\{key}' must be a JSON object")
}
}
///|
/// Load every server mcpbox can find. Names are deduplicated with the first
/// definition winning, matching the precedence of `config_candidates`.
pub async fn load_servers() -> Array[Server] raise McpError {
let servers : Array[Server] = []
let seen : Map[String, Unit] = Map([])
for candidate in config_candidates() {
let path = resolve_config_path(candidate.path)
let exists = @fs.exists(path) catch { _ => false }
if !exists {
continue
}
let text = @fs.read_file(path).text() catch {
e => raise fail("cannot read \{path}: \{e.to_string()}")
}
let document = parse_jsonc(text)
let found = servers_from_document(document, candidate.key, path)
for server in found {
if !seen.contains(server.name) {
seen[server.name] = ()
servers.push(server)
}
}
}
servers
}
///|
/// The config files that exist on this machine, in precedence order, with the
/// number of servers each contributes. Used by `mcpbox config`.
pub async fn config_files() -> Array[(String, Int32)] raise McpError {
let out : Array[(String, Int32)] = []
for candidate in config_candidates() {
let path = resolve_config_path(candidate.path)
let exists = @fs.exists(path) catch { _ => false }
if !exists {
continue
}
let text = @fs.read_file(path).text() catch {
e => raise fail("cannot read \{path}: \{e.to_string()}")
}
let document = parse_jsonc(text)
let found = servers_from_document(document, candidate.key, path)
out.push((path, found.length()))
}
out
}