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,11 +1,13 @@
//! OWLEN CLI - Chat TUI client
mod cloud;
mod mcp;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use clap::{Parser, Subcommand};
use cloud::{CloudCommand, load_runtime_credentials, set_env_var};
use mcp::{McpCommand, run_mcp_command};
use owlen_core::config as core_config;
use owlen_core::{
ChatStream, Error, Provider,
@@ -54,6 +56,9 @@ enum OwlenCommand {
/// Manage Ollama Cloud credentials
#[command(subcommand)]
Cloud(CloudCommand),
/// Manage MCP server registrations
#[command(subcommand)]
Mcp(McpCommand),
/// Show manual steps for updating Owlen to the latest revision
Upgrade,
}
@@ -69,7 +74,7 @@ enum ConfigCommand {
fn build_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
match cfg.mcp.mode {
McpMode::RemotePreferred => {
let remote_result = if let Some(mcp_server) = cfg.mcp_servers.first() {
let remote_result = if let Some(mcp_server) = cfg.effective_mcp_servers().first() {
RemoteMcpClient::new_with_config(mcp_server)
} else {
RemoteMcpClient::new()
@@ -91,7 +96,7 @@ fn build_provider(cfg: &Config) -> anyhow::Result<Arc<dyn Provider>> {
}
}
McpMode::RemoteOnly => {
let mcp_server = cfg.mcp_servers.first().ok_or_else(|| {
let mcp_server = cfg.effective_mcp_servers().first().ok_or_else(|| {
anyhow::anyhow!(
"[[mcp_servers]] must be configured when [mcp].mode = \"remote_only\""
)
@@ -130,6 +135,7 @@ async fn run_command(command: OwlenCommand) -> Result<()> {
match command {
OwlenCommand::Config(config_cmd) => run_config_command(config_cmd),
OwlenCommand::Cloud(cloud_cmd) => cloud::run_cloud_command(cloud_cmd).await,
OwlenCommand::Mcp(mcp_cmd) => run_mcp_command(mcp_cmd),
OwlenCommand::Upgrade => {
println!(
"To update Owlen from source:\n git pull\n cargo install --path crates/owlen-cli --force"
@@ -157,6 +163,7 @@ fn run_config_doctor() -> Result<()> {
let config_path = core_config::default_config_path();
let existed = config_path.exists();
let mut config = config::try_load_config().unwrap_or_default();
let _ = config.refresh_mcp_servers(None);
let mut changes = Vec::new();
if !existed {
@@ -205,7 +212,7 @@ fn run_config_doctor() -> Result<()> {
config.mcp.warn_on_legacy = true;
changes.push("converted [mcp].mode = 'legacy' to 'local_only'".to_string());
}
McpMode::RemoteOnly if config.mcp_servers.is_empty() => {
McpMode::RemoteOnly if config.effective_mcp_servers().is_empty() => {
config.mcp.mode = McpMode::RemotePreferred;
config.mcp.allow_fallback = true;
changes.push(
@@ -213,7 +220,9 @@ fn run_config_doctor() -> Result<()> {
.to_string(),
);
}
McpMode::RemotePreferred if !config.mcp.allow_fallback && config.mcp_servers.is_empty() => {
McpMode::RemotePreferred
if !config.mcp.allow_fallback && config.effective_mcp_servers().is_empty() =>
{
config.mcp.allow_fallback = true;
changes.push(
"enabled [mcp].allow_fallback because no remote servers are configured".to_string(),
@@ -369,6 +378,7 @@ async fn main() -> Result<()> {
let color_support = detect_terminal_color_support();
// Load configuration (or fall back to defaults) for the session controller.
let mut cfg = config::try_load_config().unwrap_or_default();
let _ = cfg.refresh_mcp_servers(None);
if let Some(previous_theme) = apply_terminal_theme(&mut cfg, &color_support) {
let term_label = match &color_support {
TerminalColorSupport::Limited { term } => Cow::from(term.as_str()),
@@ -398,7 +408,7 @@ async fn main() -> Result<()> {
Ok(_) => provider,
Err(err) => {
let hint = if matches!(cfg.mcp.mode, McpMode::RemotePreferred | McpMode::RemoteOnly)
&& !cfg.mcp_servers.is_empty()
&& !cfg.effective_mcp_servers().is_empty()
{
"Ensure the configured MCP server is running and reachable."
} else {
@@ -523,7 +533,7 @@ async fn run_app(
}
}
Some(session_event) = session_rx.recv() => {
app.handle_session_event(session_event)?;
app.handle_session_event(session_event).await?;
}
_ = tokio::time::sleep(sleep_duration) => {}
}