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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user