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