feat(cli): add MCP management subcommand with add/list/remove commands

Introduce `McpCommand` enum and handlers in `owlen-cli` to manage MCP server registrations, including adding, listing, and removing servers across configuration scopes. Add scoped configuration support (`ScopedMcpServer`, `McpConfigScope`) and OAuth token handling in core config, alongside runtime refresh of MCP servers. Implement toast notifications in the TUI (`render_toasts`, `Toast`, `ToastLevel`) and integrate async handling for session events. Update config loading, validation, and schema versioning to accommodate new MCP scopes and resources. Add `httpmock` as a dev dependency for testing.
This commit is contained in:
2025-10-13 17:54:14 +02:00
parent 0da8a3f193
commit 690f5c7056
23 changed files with 3388 additions and 74 deletions

View File

@@ -1,13 +1,15 @@
use crate::Error;
use crate::ProviderConfig;
use crate::Result;
use crate::mode::ModeConfig;
use crate::ui::RoleLabelDisplay;
use serde::de::{self, Deserializer, Visitor};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
/// Default location for the OWLEN configuration file
@@ -54,6 +56,21 @@ pub struct Config {
/// External MCP server definitions
#[serde(default)]
pub mcp_servers: Vec<McpServerConfig>,
/// User-scoped resource definitions
#[serde(default)]
pub mcp_resources: Vec<McpResourceConfig>,
/// Resolved MCP servers across scopes (runtime only).
#[serde(skip)]
pub scoped_mcp_servers: Vec<ScopedMcpServer>,
/// Effective MCP servers after applying precedence rules (runtime only).
#[serde(skip)]
pub effective_mcp_servers: Vec<McpServerConfig>,
/// Resolved MCP resources across scopes (runtime only).
#[serde(skip)]
pub scoped_mcp_resources: Vec<ScopedMcpResource>,
/// Effective MCP resources after precedence (runtime only).
#[serde(skip)]
pub effective_mcp_resources: Vec<McpResourceConfig>,
}
impl Default for Config {
@@ -74,6 +91,11 @@ impl Default for Config {
tools: ToolSettings::default(),
modes: ModeConfig::default(),
mcp_servers: Vec::new(),
mcp_resources: Vec::new(),
scoped_mcp_servers: Vec::new(),
effective_mcp_servers: Vec::new(),
scoped_mcp_resources: Vec::new(),
effective_mcp_resources: Vec::new(),
}
}
}
@@ -94,6 +116,9 @@ pub struct McpServerConfig {
/// Optional environment variable map for the process.
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
/// Optional OAuth configuration for remote servers.
#[serde(default)]
pub oauth: Option<McpOAuthConfig>,
}
impl McpServerConfig {
@@ -102,6 +127,126 @@ impl McpServerConfig {
}
}
/// OAuth configuration for MCP servers that require delegated authentication.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpOAuthConfig {
/// Public client identifier registered with the authorization server.
pub client_id: String,
/// Optional client secret for confidential clients.
#[serde(default)]
pub client_secret: Option<String>,
/// OAuth authorization endpoint (used for web-based flows).
pub authorize_url: String,
/// OAuth token endpoint.
pub token_url: String,
/// Optional device authorization endpoint for device-code flows.
#[serde(default)]
pub device_authorization_url: Option<String>,
/// Optional redirect URL (PKCE / authorization-code flows).
#[serde(default)]
pub redirect_url: Option<String>,
/// Requested OAuth scopes.
#[serde(default)]
pub scopes: Vec<String>,
/// Environment variable name populated with the bearer access token when spawning stdio servers.
#[serde(default)]
pub token_env: Option<String>,
/// Optional HTTP header name for bearer authentication (defaults to "Authorization").
#[serde(default)]
pub header: Option<String>,
/// Optional prefix prepended to the access token (defaults to "Bearer ").
#[serde(default)]
pub header_prefix: Option<String>,
}
impl McpOAuthConfig {
pub fn header_name(&self) -> &str {
self.header.as_deref().unwrap_or("Authorization")
}
pub fn header_prefix(&self) -> &str {
self.header_prefix.as_deref().unwrap_or("Bearer ")
}
}
/// Scope for MCP server configuration entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum McpConfigScope {
/// User-level configuration stored under the user's config directory.
User,
/// Project configuration stored in the repository (e.g. `.mcp.json`).
Project,
/// Local overrides stored alongside the project but excluded from version control.
Local,
}
impl McpConfigScope {
fn precedence_iter() -> impl Iterator<Item = Self> {
[
McpConfigScope::Local,
McpConfigScope::Project,
McpConfigScope::User,
]
.into_iter()
}
fn as_str(self) -> &'static str {
match self {
McpConfigScope::User => "user",
McpConfigScope::Project => "project",
McpConfigScope::Local => "local",
}
}
}
impl fmt::Display for McpConfigScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for McpConfigScope {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"user" => Ok(McpConfigScope::User),
"project" => Ok(McpConfigScope::Project),
"local" => Ok(McpConfigScope::Local),
other => Err(format!("Unknown MCP scope '{other}'")),
}
}
}
/// A resolved MCP server entry annotated with its configuration scope.
#[derive(Debug, Clone)]
pub struct ScopedMcpServer {
pub scope: McpConfigScope,
pub config: McpServerConfig,
}
/// Configuration for a predefined MCP resource reference.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpResourceConfig {
/// Named MCP server that owns this resource.
pub server: String,
/// URI or path identifying the resource within the server.
pub uri: String,
/// Optional short title displayed in UI.
#[serde(default)]
pub title: Option<String>,
/// Optional detailed description shown in tooltips.
#[serde(default)]
pub description: Option<String>,
}
/// Resource entry annotated with its originating scope.
#[derive(Debug, Clone)]
pub struct ScopedMcpResource {
pub scope: McpConfigScope,
pub config: McpResourceConfig,
}
impl Config {
fn default_schema_version() -> String {
CONFIG_SCHEMA_VERSION.to_string()
@@ -138,18 +283,22 @@ impl Config {
config.mcp.apply_backward_compat();
config.apply_schema_migrations(&previous_version);
config.expand_provider_env_vars()?;
config.refresh_mcp_servers(None)?;
config.validate()?;
Ok(config)
} else {
let mut config = Config::default();
config.expand_provider_env_vars()?;
config.refresh_mcp_servers(None)?;
Ok(config)
}
}
/// Persist configuration to disk
pub fn save(&self, path: Option<&Path>) -> Result<()> {
self.validate()?;
let mut validator = self.clone();
validator.refresh_mcp_servers(None)?;
validator.validate()?;
let path = match path {
Some(path) => path.to_path_buf(),
@@ -214,6 +363,192 @@ impl Config {
Ok(())
}
/// Refresh the resolved MCP server list by loading scope-specific definitions.
pub fn refresh_mcp_servers(&mut self, project_hint: Option<&Path>) -> Result<()> {
let mut scoped_servers = Vec::new();
let mut scoped_resources = Vec::new();
let mut user_servers = self.mcp_servers.clone();
expand_mcp_servers(&mut user_servers, "config.mcp_servers")?;
for server in user_servers {
scoped_servers.push(ScopedMcpServer {
scope: McpConfigScope::User,
config: server,
});
}
let mut user_resources = self.mcp_resources.clone();
expand_mcp_resources(&mut user_resources, "config.mcp_resources")?;
for resource in user_resources {
scoped_resources.push(ScopedMcpResource {
scope: McpConfigScope::User,
config: resource,
});
}
for scope in [McpConfigScope::Project, McpConfigScope::Local] {
if let Some(path) = mcp_scope_path(scope, project_hint) {
let mut file = read_scope_config(&path)?;
let server_context = format!("mcp.{scope}.servers");
expand_mcp_servers(&mut file.servers, &server_context)?;
for server in file.servers {
scoped_servers.push(ScopedMcpServer {
scope,
config: server,
});
}
let resource_context = format!("mcp.{scope}.resources");
expand_mcp_resources(&mut file.resources, &resource_context)?;
for resource in file.resources {
scoped_resources.push(ScopedMcpResource {
scope,
config: resource,
});
}
}
}
let mut effective_servers = Vec::new();
let mut seen_servers = HashSet::new();
for scope in McpConfigScope::precedence_iter() {
for entry in scoped_servers.iter().filter(|entry| entry.scope == scope) {
if seen_servers.insert(entry.config.name.clone()) {
effective_servers.push(entry.config.clone());
}
}
}
let mut effective_resources = Vec::new();
let mut seen_resources: HashSet<(String, String)> = HashSet::new();
for scope in McpConfigScope::precedence_iter() {
for entry in scoped_resources.iter().filter(|entry| entry.scope == scope) {
let key = (entry.config.server.clone(), entry.config.uri.clone());
if seen_resources.insert(key) {
effective_resources.push(entry.config.clone());
}
}
}
self.scoped_mcp_servers = scoped_servers;
self.effective_mcp_servers = effective_servers;
self.scoped_mcp_resources = scoped_resources;
self.effective_mcp_resources = effective_resources;
Ok(())
}
/// Return the merged MCP servers using scope precedence (local > project > user).
pub fn effective_mcp_servers(&self) -> &[McpServerConfig] {
&self.effective_mcp_servers
}
/// Return MCP servers annotated with their originating scope.
pub fn scoped_mcp_servers(&self) -> &[ScopedMcpServer] {
&self.scoped_mcp_servers
}
/// Return merged MCP resources using scope precedence (local > project > user).
pub fn effective_mcp_resources(&self) -> &[McpResourceConfig] {
&self.effective_mcp_resources
}
/// Return scoped MCP resources with their origin scope metadata.
pub fn scoped_mcp_resources(&self) -> &[ScopedMcpResource] {
&self.scoped_mcp_resources
}
/// Locate a configured resource by server and URI.
pub fn find_resource(&self, server: &str, uri: &str) -> Option<&McpResourceConfig> {
self.effective_mcp_resources
.iter()
.find(|resource| resource.server == server && resource.uri == uri)
}
/// Add or replace an MCP server definition within the specified scope.
pub fn add_mcp_server(
&mut self,
scope: McpConfigScope,
server: McpServerConfig,
project_hint: Option<&Path>,
) -> Result<()> {
match scope {
McpConfigScope::User => {
self.mcp_servers
.retain(|existing| existing.name != server.name);
self.mcp_servers.push(server);
}
other => {
let path = mcp_scope_path(other, project_hint).ok_or_else(|| {
Error::Config(format!(
"Unable to resolve project root for MCP scope '{}'",
other
))
})?;
let mut file = read_scope_config(&path)?;
file.servers.retain(|existing| existing.name != server.name);
file.servers.push(server);
write_scope_config(&path, &file)?;
}
}
self.refresh_mcp_servers(project_hint)?;
Ok(())
}
/// Remove an MCP server from the given scope, or infer the scope if omitted.
pub fn remove_mcp_server(
&mut self,
scope: Option<McpConfigScope>,
name: &str,
project_hint: Option<&Path>,
) -> Result<Option<McpConfigScope>> {
let target_scope = if let Some(scope) = scope {
scope
} else {
self.refresh_mcp_servers(project_hint)?;
match self
.scoped_mcp_servers
.iter()
.find(|entry| entry.config.name == name)
{
Some(entry) => entry.scope,
None => return Ok(None),
}
};
let removed = match target_scope {
McpConfigScope::User => {
let before = self.mcp_servers.len();
self.mcp_servers.retain(|entry| entry.name != name);
before != self.mcp_servers.len()
}
other => {
let path = mcp_scope_path(other, project_hint).ok_or_else(|| {
Error::Config(format!(
"Unable to resolve project root for MCP scope '{}'",
other
))
})?;
let mut file = read_scope_config(&path)?;
let before = file.servers.len();
file.servers.retain(|entry| entry.name != name);
if before == file.servers.len() {
false
} else {
write_scope_config(&path, &file)?;
true
}
}
};
if removed {
self.refresh_mcp_servers(project_hint)?;
Ok(Some(target_scope))
} else {
Ok(None)
}
}
/// Validate configuration invariants and surface actionable error messages.
pub fn validate(&self) -> Result<()> {
self.validate_default_provider()?;
@@ -284,9 +619,15 @@ impl Config {
}
fn validate_mcp_settings(&self) -> Result<()> {
let has_effective_servers = if self.effective_mcp_servers.is_empty() {
!self.mcp_servers.is_empty()
} else {
!self.effective_mcp_servers.is_empty()
};
match self.mcp.mode {
McpMode::RemoteOnly => {
if self.mcp_servers.is_empty() {
if !has_effective_servers {
return Err(crate::Error::Config(
"[mcp].mode = 'remote_only' requires at least one [[mcp_servers]] entry"
.to_string(),
@@ -294,7 +635,7 @@ impl Config {
}
}
McpMode::RemotePreferred => {
if !self.mcp.allow_fallback && self.mcp_servers.is_empty() {
if !self.mcp.allow_fallback && !has_effective_servers {
return Err(crate::Error::Config(
"[mcp].allow_fallback = false requires at least one [[mcp_servers]] entry"
.to_string(),
@@ -313,26 +654,13 @@ impl Config {
}
fn validate_mcp_servers(&self) -> Result<()> {
for server in &self.mcp_servers {
if server.name.trim().is_empty() {
return Err(crate::Error::Config(
"Each [[mcp_servers]] entry must include a non-empty name".to_string(),
));
if self.scoped_mcp_servers.is_empty() {
for server in &self.mcp_servers {
validate_mcp_server_entry(server, McpConfigScope::User)?;
}
if server.command.trim().is_empty() {
return Err(crate::Error::Config(format!(
"MCP server '{}' must define a command or endpoint",
server.name
)));
}
let transport = server.transport.to_lowercase();
if !matches!(transport.as_str(), "stdio" | "http" | "websocket") {
return Err(crate::Error::Config(format!(
"Unknown MCP transport '{}' for server '{}'",
server.transport, server.name
)));
} else {
for entry in &self.scoped_mcp_servers {
validate_mcp_server_entry(&entry.config, entry.scope)?;
}
}
@@ -349,6 +677,58 @@ fn default_ollama_provider_config() -> ProviderConfig {
}
}
fn validate_mcp_server_entry(server: &McpServerConfig, scope: McpConfigScope) -> Result<()> {
if server.name.trim().is_empty() {
return Err(Error::Config(format!(
"Each MCP server entry must include a non-empty name (scope: {scope})"
)));
}
if server.command.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' must define a command or endpoint (scope: {scope})",
server.name
)));
}
let transport = server.transport.to_lowercase();
if !matches!(transport.as_str(), "stdio" | "http" | "websocket") {
return Err(Error::Config(format!(
"Unknown MCP transport '{}' for server '{}' (scope: {scope})",
server.transport, server.name
)));
}
if let Some(oauth) = &server.oauth {
if oauth.client_id.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' defines OAuth without a client_id",
server.name
)));
}
if oauth.authorize_url.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' defines OAuth without an authorize_url",
server.name
)));
}
if oauth.token_url.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' defines OAuth without a token_url",
server.name
)));
}
if oauth.device_authorization_url.is_none() && oauth.redirect_url.is_none() {
return Err(Error::Config(format!(
"MCP server '{}' must define either device_authorization_url or redirect_url for OAuth flows",
server.name
)));
}
}
Ok(())
}
fn expand_provider_entry(provider_name: &str, provider: &mut ProviderConfig) -> Result<()> {
if let Some(ref mut base_url) = provider.base_url {
let expanded = expand_env_string(
@@ -379,6 +759,136 @@ fn expand_provider_entry(provider_name: &str, provider: &mut ProviderConfig) ->
Ok(())
}
fn expand_mcp_servers(servers: &mut [McpServerConfig], field_path: &str) -> Result<()> {
for (idx, server) in servers.iter_mut().enumerate() {
expand_mcp_server_entry(server, field_path, idx)?;
}
Ok(())
}
fn expand_mcp_server_entry(
server: &mut McpServerConfig,
field_path: &str,
index: usize,
) -> Result<()> {
server.command = expand_env_string(
server.command.as_str(),
&format!("{field_path}[{index}].command"),
)?;
for (arg_idx, arg) in server.args.iter_mut().enumerate() {
*arg = expand_env_string(
arg.as_str(),
&format!("{field_path}[{index}].args[{arg_idx}]"),
)?;
}
for (env_key, env_value) in server.env.iter_mut() {
*env_value = expand_env_string(
env_value.as_str(),
&format!("{field_path}[{index}].env.{env_key}"),
)?;
}
if let Some(oauth) = server.oauth.as_mut() {
oauth.client_id = expand_env_string(
oauth.client_id.as_str(),
&format!("{field_path}[{index}].oauth.client_id"),
)?;
oauth.authorize_url = expand_env_string(
oauth.authorize_url.as_str(),
&format!("{field_path}[{index}].oauth.authorize_url"),
)?;
oauth.token_url = expand_env_string(
oauth.token_url.as_str(),
&format!("{field_path}[{index}].oauth.token_url"),
)?;
if let Some(secret) = oauth.client_secret.as_mut() {
*secret = expand_env_string(
secret.as_str(),
&format!("{field_path}[{index}].oauth.client_secret"),
)?;
}
if let Some(device_url) = oauth.device_authorization_url.as_mut() {
*device_url = expand_env_string(
device_url.as_str(),
&format!("{field_path}[{index}].oauth.device_authorization_url"),
)?;
}
if let Some(redirect) = oauth.redirect_url.as_mut() {
*redirect = expand_env_string(
redirect.as_str(),
&format!("{field_path}[{index}].oauth.redirect_url"),
)?;
}
if let Some(token_env) = oauth.token_env.as_mut() {
*token_env = expand_env_string(
token_env.as_str(),
&format!("{field_path}[{index}].oauth.token_env"),
)?;
}
if let Some(header) = oauth.header.as_mut() {
*header = expand_env_string(
header.as_str(),
&format!("{field_path}[{index}].oauth.header"),
)?;
}
if let Some(prefix) = oauth.header_prefix.as_mut() {
*prefix = expand_env_string(
prefix.as_str(),
&format!("{field_path}[{index}].oauth.header_prefix"),
)?;
}
for (scope_idx, scope) in oauth.scopes.iter_mut().enumerate() {
*scope = expand_env_string(
scope.as_str(),
&format!("{field_path}[{index}].oauth.scopes[{scope_idx}]"),
)?;
}
}
Ok(())
}
fn expand_mcp_resources(resources: &mut [McpResourceConfig], field_path: &str) -> Result<()> {
for (idx, resource) in resources.iter_mut().enumerate() {
expand_mcp_resource_entry(resource, field_path, idx)?;
}
Ok(())
}
fn expand_mcp_resource_entry(
resource: &mut McpResourceConfig,
field_path: &str,
index: usize,
) -> Result<()> {
resource.server = expand_env_string(
resource.server.as_str(),
&format!("{field_path}[{index}].server"),
)?;
resource.uri = expand_env_string(resource.uri.as_str(), &format!("{field_path}[{index}].uri"))?;
if let Some(title) = resource.title.as_mut() {
*title = expand_env_string(title.as_str(), &format!("{field_path}[{index}].title"))?;
}
if let Some(description) = resource.description.as_mut() {
*description = expand_env_string(
description.as_str(),
&format!("{field_path}[{index}].description"),
)?;
}
Ok(())
}
fn expand_env_string(input: &str, field_path: &str) -> Result<String> {
if !input.contains('$') {
return Ok(input.to_string());
@@ -408,6 +918,106 @@ pub fn default_config_path() -> PathBuf {
PathBuf::from(shellexpand::tilde(DEFAULT_CONFIG_PATH).as_ref())
}
#[derive(Serialize, Deserialize, Default, Clone)]
struct McpConfigFile {
#[serde(default)]
servers: Vec<McpServerConfig>,
#[serde(default)]
resources: Vec<McpResourceConfig>,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum McpConfigEnvelope {
Array(Vec<McpServerConfig>),
Object(McpConfigFile),
}
fn read_scope_config(path: &Path) -> Result<McpConfigFile> {
if !path.exists() {
return Ok(McpConfigFile::default());
}
let contents = fs::read_to_string(path).map_err(Error::Io)?;
if contents.trim().is_empty() {
return Ok(McpConfigFile::default());
}
let doc: McpConfigEnvelope = serde_json::from_str(&contents).map_err(|err| {
Error::Config(format!(
"Failed to parse MCP configuration at {}: {err}",
path.display()
))
})?;
Ok(match doc {
McpConfigEnvelope::Array(servers) => McpConfigFile {
servers,
resources: Vec::new(),
},
McpConfigEnvelope::Object(doc) => doc,
})
}
fn write_scope_config(path: &Path, file: &McpConfigFile) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(Error::Io)?;
}
let serialized = serde_json::to_string_pretty(file).map_err(|err| {
Error::Config(format!(
"Failed to serialize MCP configuration for {}: {err}",
path.display()
))
})?;
fs::write(path, serialized).map_err(Error::Io)
}
/// Resolve the configuration file path for a given scope.
pub fn mcp_scope_path(scope: McpConfigScope, project_hint: Option<&Path>) -> Option<PathBuf> {
match scope {
McpConfigScope::User => dirs::config_dir()
.or_else(|| Some(PathBuf::from(shellexpand::tilde("~/.config").as_ref())))
.map(|dir| dir.join("owlen").join("mcp.json")),
McpConfigScope::Project | McpConfigScope::Local => {
let root = project_hint
.map(PathBuf::from)
.or_else(|| discover_project_root(None))?;
if matches!(scope, McpConfigScope::Project) {
Some(root.join(".mcp.json"))
} else {
Some(root.join(".owlen").join("mcp.local.json"))
}
}
}
}
fn discover_project_root(start: Option<&Path>) -> Option<PathBuf> {
let mut current = start
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())?;
loop {
if current.join(".mcp.json").exists()
|| current.join(".owlen").exists()
|| current.join(".git").exists()
|| current.join("Cargo.toml").exists()
{
return Some(current);
}
if !current.pop() {
break;
}
}
start
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
}
/// General behaviour settings shared across clients
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralSettings {
@@ -1173,6 +1783,7 @@ mod tests {
transport: "udp".into(),
args: Vec::new(),
env: std::collections::HashMap::new(),
oauth: None,
}];
let result = config.validate();
assert!(
@@ -1186,4 +1797,113 @@ mod tests {
config.mcp.mode = McpMode::LocalOnly;
assert!(config.validate().is_ok());
}
#[test]
fn refresh_mcp_servers_merges_scopes_with_precedence() {
let temp = tempfile::tempdir().expect("tempdir");
let project_root = temp.path();
std::fs::write(
project_root.join(".mcp.json"),
r#"{
"servers": [
{ "name": "shared", "command": "project-cmd", "transport": "stdio" },
{ "name": "project-only", "command": "proj", "transport": "stdio" }
],
"resources": [
{ "server": "github", "uri": "issue://123", "title": "Project Issue" },
{ "server": "docs", "uri": "page://start", "title": "Project Doc" }
]
}"#,
)
.expect("write project scope");
let local_dir = project_root.join(".owlen");
std::fs::create_dir_all(&local_dir).expect("local dir");
std::fs::write(
local_dir.join("mcp.local.json"),
r#"{
"servers": [
{ "name": "shared", "command": "local-cmd", "transport": "stdio" }
],
"resources": [
{ "server": "github", "uri": "issue://123", "title": "Local Override" }
]
}"#,
)
.expect("write local scope");
let mut config = Config::default();
config.mcp_servers.push(McpServerConfig {
name: "shared".into(),
command: "user-cmd".into(),
args: Vec::new(),
transport: "stdio".into(),
env: std::collections::HashMap::new(),
oauth: None,
});
config.mcp_resources.push(McpResourceConfig {
server: "github".into(),
uri: "issue://123".into(),
title: Some("User Issue".into()),
description: None,
});
config
.refresh_mcp_servers(Some(project_root))
.expect("refresh scopes");
// We should have four scoped entries (user + two project + local) and precedence should select local
assert_eq!(config.scoped_mcp_servers().len(), 4);
let effective = config.effective_mcp_servers();
assert_eq!(effective.len(), 2); // shared + project-only
assert_eq!(effective[0].command, "local-cmd");
assert_eq!(effective[0].name, "shared");
assert_eq!(config.scoped_mcp_resources().len(), 4);
let effective_resources = config.effective_mcp_resources();
assert_eq!(effective_resources.len(), 2);
assert_eq!(
effective_resources
.iter()
.find(|res| res.server == "github")
.and_then(|res| res.title.as_deref()),
Some("Local Override")
);
}
#[test]
fn remove_mcp_server_reports_scope() {
let temp = tempfile::tempdir().expect("tempdir");
let project_root = temp.path();
std::fs::write(
project_root.join(".mcp.json"),
r#"{ "servers": [{ "name": "project", "command": "proj", "transport": "stdio" }] }"#,
)
.expect("write project scope");
let mut config = Config::default();
config.mcp_servers.push(McpServerConfig {
name: "user".into(),
command: "user".into(),
args: Vec::new(),
transport: "stdio".into(),
env: std::collections::HashMap::new(),
oauth: None,
});
config
.refresh_mcp_servers(Some(project_root))
.expect("refresh scopes");
// Remove without specifying scope should pick highest precedence (project)
let removed_scope = config
.remove_mcp_server(None, "project", Some(project_root))
.expect("remove call");
assert_eq!(removed_scope, Some(McpConfigScope::Project));
// Remove the remaining user scope explicitly
let removed_scope = config
.remove_mcp_server(Some(McpConfigScope::User), "user", Some(project_root))
.expect("remove user");
assert_eq!(removed_scope, Some(McpConfigScope::User));
}
}