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

@@ -3,7 +3,10 @@
/// Provides a unified interface for creating MCP clients based on configuration.
/// Supports switching between local (in-process) and remote (STDIO) execution modes.
use super::client::McpClient;
use super::{LocalMcpClient, remote_client::RemoteMcpClient};
use super::{
LocalMcpClient,
remote_client::{McpRuntimeSecrets, RemoteMcpClient},
};
use crate::config::{Config, McpMode};
use crate::tools::registry::ToolRegistry;
use crate::validation::SchemaValidator;
@@ -33,6 +36,14 @@ impl McpClientFactory {
/// Create an MCP client based on the current configuration.
pub fn create(&self) -> Result<Box<dyn McpClient>> {
self.create_with_secrets(None)
}
/// Create an MCP client using optional runtime secrets (OAuth tokens, env overrides).
pub fn create_with_secrets(
&self,
runtime: Option<McpRuntimeSecrets>,
) -> Result<Box<dyn McpClient>> {
match self.config.mcp.mode {
McpMode::Disabled => Err(Error::Config(
"MCP mode is set to 'disabled'; tooling cannot function in this configuration."
@@ -48,14 +59,14 @@ impl McpClientFactory {
)))
}
McpMode::RemoteOnly => {
let server_cfg = self.config.mcp_servers.first().ok_or_else(|| {
let server_cfg = self.config.effective_mcp_servers().first().ok_or_else(|| {
Error::Config(
"MCP mode 'remote_only' requires at least one entry in [[mcp_servers]]"
.to_string(),
)
})?;
RemoteMcpClient::new_with_config(server_cfg)
RemoteMcpClient::new_with_runtime(server_cfg, runtime)
.map(|client| Box::new(client) as Box<dyn McpClient>)
.map_err(|e| {
Error::Config(format!(
@@ -65,8 +76,8 @@ impl McpClientFactory {
})
}
McpMode::RemotePreferred => {
if let Some(server_cfg) = self.config.mcp_servers.first() {
match RemoteMcpClient::new_with_config(server_cfg) {
if let Some(server_cfg) = self.config.effective_mcp_servers().first() {
match RemoteMcpClient::new_with_runtime(server_cfg, runtime.clone()) {
Ok(client) => {
info!(
"Connected to remote MCP server '{}' via {} transport.",
@@ -125,7 +136,8 @@ mod tests {
#[test]
fn test_factory_creates_local_client_when_no_servers_configured() {
let config = Config::default();
let mut config = Config::default();
config.refresh_mcp_servers(None).unwrap();
let factory = build_factory(config);
@@ -139,6 +151,7 @@ mod tests {
let mut config = Config::default();
config.mcp.mode = McpMode::RemoteOnly;
config.mcp_servers.clear();
config.refresh_mcp_servers(None).unwrap();
let factory = build_factory(config);
let result = factory.create();
@@ -156,7 +169,9 @@ mod tests {
args: Vec::new(),
transport: "stdio".to_string(),
env: std::collections::HashMap::new(),
oauth: None,
}];
config.refresh_mcp_servers(None).unwrap();
let factory = build_factory(config);
let result = factory.create();