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

@@ -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