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();

View File

@@ -305,6 +305,7 @@ mod tests {
args: vec![],
transport: "http".to_string(),
env: std::collections::HashMap::new(),
oauth: None,
};
if let Ok(client) = RemoteMcpClient::new_with_config(&config) {

View File

@@ -12,6 +12,7 @@ use anyhow::anyhow;
use futures::{StreamExt, future::BoxFuture, stream};
use reqwest::Client as HttpClient;
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -39,6 +40,15 @@ pub struct RemoteMcpClient {
ws_endpoint: Option<String>,
// Incrementing request identifier.
next_id: AtomicU64,
// Optional HTTP header (name, value) injected into every request.
http_header: Option<(String, String)>,
}
/// Runtime secrets provided when constructing an MCP client.
#[derive(Debug, Default, Clone)]
pub struct McpRuntimeSecrets {
pub env_overrides: HashMap<String, String>,
pub http_header: Option<(String, String)>,
}
impl RemoteMcpClient {
@@ -48,6 +58,14 @@ impl RemoteMcpClient {
/// Spawn an external MCP server based on a configuration entry.
/// The server must communicate over STDIO (the only supported transport).
pub fn new_with_config(config: &crate::config::McpServerConfig) -> Result<Self> {
Self::new_with_runtime(config, None)
}
pub fn new_with_runtime(
config: &crate::config::McpServerConfig,
runtime: Option<McpRuntimeSecrets>,
) -> Result<Self> {
let mut runtime = runtime.unwrap_or_default();
let transport = config.transport.to_lowercase();
match transport.as_str() {
"stdio" => {
@@ -64,6 +82,9 @@ impl RemoteMcpClient {
for (k, v) in config.env.iter() {
cmd.env(k, v);
}
for (k, v) in runtime.env_overrides.drain() {
cmd.env(k, v);
}
let mut child = cmd.spawn().map_err(|e| {
Error::Io(std::io::Error::new(
@@ -92,6 +113,7 @@ impl RemoteMcpClient {
ws_stream: None,
ws_endpoint: None,
next_id: AtomicU64::new(1),
http_header: None,
})
}
"http" => {
@@ -109,6 +131,7 @@ impl RemoteMcpClient {
ws_stream: None,
ws_endpoint: None,
next_id: AtomicU64::new(1),
http_header: runtime.http_header.take(),
})
}
"websocket" => {
@@ -132,6 +155,7 @@ impl RemoteMcpClient {
ws_stream: Some(Arc::new(Mutex::new(ws_stream))),
ws_endpoint: Some(ws_url),
next_id: AtomicU64::new(1),
http_header: runtime.http_header.take(),
})
}
other => Err(Error::NotImplemented(format!(
@@ -171,6 +195,7 @@ impl RemoteMcpClient {
args: Vec::new(),
transport: "stdio".to_string(),
env: std::collections::HashMap::new(),
oauth: None,
};
Self::new_with_config(&config)
}
@@ -193,8 +218,11 @@ impl RemoteMcpClient {
.http_endpoint
.as_ref()
.ok_or_else(|| Error::Network("Missing HTTP endpoint".into()))?;
let resp = client
.post(endpoint)
let mut builder = client.post(endpoint);
if let Some((ref header_name, ref header_value)) = self.http_header {
builder = builder.header(header_name, header_value);
}
let resp = builder
.json(&request)
.send()
.await