feat(mcp): add MCP client abstraction and feature flag

Introduce the foundation for the Multi-Client Provider (MCP) architecture.

This phase includes:
- A new `McpClient` trait to abstract tool execution.
- A `LocalMcpClient` that executes tools in-process for backward compatibility ("legacy mode").
- A placeholder `RemoteMcpClient` for future development.
- An `McpMode` enum in the configuration (`mcp.mode`) to toggle between `legacy` and `enabled` modes, defaulting to `legacy`.
- Refactoring of `SessionController` to use the `McpClient` abstraction, decoupling it from the tool registry.

This lays the groundwork for routing tool calls to a remote MCP server in subsequent phases.
This commit is contained in:
2025-10-06 20:03:01 +02:00
parent 235f84fa19
commit 67381b02db
5 changed files with 211 additions and 84 deletions

View File

@@ -14,6 +14,9 @@ pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
pub struct Config { pub struct Config {
/// General application settings /// General application settings
pub general: GeneralSettings, pub general: GeneralSettings,
/// MCP (Multi-Client-Provider) settings
#[serde(default)]
pub mcp: McpSettings,
/// Provider specific configuration keyed by provider name /// Provider specific configuration keyed by provider name
#[serde(default)] #[serde(default)]
pub providers: HashMap<String, ProviderConfig>, pub providers: HashMap<String, ProviderConfig>,
@@ -48,6 +51,7 @@ impl Default for Config {
Self { Self {
general: GeneralSettings::default(), general: GeneralSettings::default(),
mcp: McpSettings::default(),
providers, providers,
ui: UiSettings::default(), ui: UiSettings::default(),
storage: StorageSettings::default(), storage: StorageSettings::default(),
@@ -202,6 +206,21 @@ impl Default for GeneralSettings {
} }
} }
/// MCP (Multi-Client-Provider) settings
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpSettings {
#[serde(default)]
pub mode: McpMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum McpMode {
#[default]
Legacy,
Enabled,
}
/// Privacy controls governing network access and storage /// Privacy controls governing network access and storage
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacySettings { pub struct PrivacySettings {

View File

@@ -76,4 +76,7 @@ pub enum Error {
#[error("Unknown error: {0}")] #[error("Unknown error: {0}")]
Unknown(String), Unknown(String),
#[error("Not implemented: {0}")]
NotImplemented(String),
} }

View File

@@ -0,0 +1,33 @@
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::{Error, Result};
use async_trait::async_trait;
/// Trait for a client that can interact with an MCP server
#[async_trait]
pub trait McpClient: Send + Sync {
/// List the tools available on the server
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
/// Call a tool on the server
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse>;
}
/// Placeholder for a client that connects to a remote MCP server.
pub struct RemoteMcpClient;
#[async_trait]
impl McpClient for RemoteMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
// TODO: Implement remote call
Err(Error::NotImplemented(
"Remote MCP client is not implemented".to_string(),
))
}
async fn call_tool(&self, _call: McpToolCall) -> Result<McpToolResponse> {
// TODO: Implement remote call
Err(Error::NotImplemented(
"Remote MCP client is not implemented".to_string(),
))
}
}

View File

@@ -1,12 +1,16 @@
use crate::tools::registry::ToolRegistry; use crate::tools::registry::ToolRegistry;
use crate::validation::SchemaValidator; use crate::validation::SchemaValidator;
use crate::Result; use crate::Result;
use async_trait::async_trait;
use client::McpClient;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
pub mod client;
/// Descriptor for a tool exposed over MCP /// Descriptor for a tool exposed over MCP
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDescriptor { pub struct McpToolDescriptor {
@@ -80,3 +84,26 @@ impl McpServer {
fn duration_to_millis(duration: Duration) -> u128 { fn duration_to_millis(duration: Duration) -> u128 {
duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis()) duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis())
} }
pub struct LocalMcpClient {
server: McpServer,
}
impl LocalMcpClient {
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
Self {
server: McpServer::new(registry, validator),
}
}
}
#[async_trait]
impl McpClient for LocalMcpClient {
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
Ok(self.server.list_tools())
}
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
self.server.call_tool(call).await
}
}

View File

@@ -1,10 +1,12 @@
use crate::config::Config; use crate::config::{Config, McpMode};
use crate::consent::ConsentManager; use crate::consent::ConsentManager;
use crate::conversation::ConversationManager; use crate::conversation::ConversationManager;
use crate::credentials::CredentialManager; use crate::credentials::CredentialManager;
use crate::encryption::{self, VaultHandle}; use crate::encryption::{self, VaultHandle};
use crate::formatting::MessageFormatter; use crate::formatting::MessageFormatter;
use crate::input::InputBuffer; use crate::input::InputBuffer;
use crate::mcp::client::{McpClient, RemoteMcpClient};
use crate::mcp::{LocalMcpClient, McpToolCall};
use crate::model::ModelManager; use crate::model::ModelManager;
use crate::provider::{ChatStream, Provider}; use crate::provider::{ChatStream, Provider};
use crate::storage::{SessionMeta, StorageManager}; use crate::storage::{SessionMeta, StorageManager};
@@ -102,6 +104,7 @@ pub struct SessionController {
consent_manager: Arc<Mutex<ConsentManager>>, consent_manager: Arc<Mutex<ConsentManager>>,
tool_registry: Arc<ToolRegistry>, tool_registry: Arc<ToolRegistry>,
schema_validator: Arc<SchemaValidator>, schema_validator: Arc<SchemaValidator>,
mcp_client: Arc<dyn McpClient>,
storage: Arc<StorageManager>, storage: Arc<StorageManager>,
vault: Option<Arc<Mutex<VaultHandle>>>, vault: Option<Arc<Mutex<VaultHandle>>>,
master_key: Option<Arc<Vec<u8>>>, master_key: Option<Arc<Vec<u8>>>,
@@ -109,6 +112,83 @@ pub struct SessionController {
enable_code_tools: bool, // Whether to enable code execution tools (code client only) enable_code_tools: bool, // Whether to enable code execution tools (code client only)
} }
fn build_tools(
config: &Config,
enable_code_tools: bool,
consent_manager: Arc<Mutex<ConsentManager>>,
credential_manager: Option<Arc<CredentialManager>>,
vault: Option<Arc<Mutex<VaultHandle>>>,
) -> Result<(Arc<ToolRegistry>, Arc<SchemaValidator>)> {
let mut registry = ToolRegistry::new();
let mut validator = SchemaValidator::new();
for (name, schema) in get_builtin_schemas() {
if let Err(err) = validator.register_schema(&name, schema) {
warn!("Failed to register built-in schema {name}: {err}");
}
}
if config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search")
&& config.tools.web_search.enabled
&& config.privacy.enable_remote_search
{
let tool = WebSearchTool::new(
consent_manager.clone(),
credential_manager.clone(),
vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Register web_search_detailed tool (provides snippets)
if config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search") // Same permission as web_search
&& config.tools.web_search.enabled
&& config.privacy.enable_remote_search
{
let tool = WebSearchDetailedTool::new(
consent_manager.clone(),
credential_manager.clone(),
vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Code execution tool - only available in code client
if enable_code_tools
&& config
.security
.allowed_tools
.iter()
.any(|tool| tool == "code_exec")
&& config.tools.code_exec.enabled
{
let tool = CodeExecTool::new(config.tools.code_exec.allowed_languages.clone());
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
Ok((Arc::new(registry), Arc::new(validator)))
}
impl SessionController { impl SessionController {
/// Create a new controller with the given provider and configuration /// Create a new controller with the given provider and configuration
/// ///
@@ -175,7 +255,23 @@ impl SessionController {
let model_manager = ModelManager::new(config.general.model_cache_ttl()); let model_manager = ModelManager::new(config.general.model_cache_ttl());
let mut controller = Self { let (tool_registry, schema_validator) = build_tools(
&config,
enable_code_tools,
consent_manager.clone(),
credential_manager.clone(),
vault_handle.clone(),
)?;
let mcp_client: Arc<dyn McpClient> = match config.mcp.mode {
McpMode::Legacy => Arc::new(LocalMcpClient::new(
tool_registry.clone(),
schema_validator.clone(),
)),
McpMode::Enabled => Arc::new(RemoteMcpClient {}),
};
let controller = Self {
provider, provider,
conversation, conversation,
model_manager, model_manager,
@@ -183,8 +279,9 @@ impl SessionController {
formatter, formatter,
config, config,
consent_manager, consent_manager,
tool_registry: Arc::new(ToolRegistry::new()), tool_registry,
schema_validator: Arc::new(SchemaValidator::new()), schema_validator,
mcp_client,
storage, storage,
vault: vault_handle, vault: vault_handle,
master_key, master_key,
@@ -192,8 +289,6 @@ impl SessionController {
enable_code_tools, enable_code_tools,
}; };
controller.rebuild_tools()?;
Ok(controller) Ok(controller)
} }
@@ -416,78 +511,24 @@ impl SessionController {
} }
fn rebuild_tools(&mut self) -> Result<()> { fn rebuild_tools(&mut self) -> Result<()> {
let mut registry = ToolRegistry::new(); let (registry, validator) = build_tools(
let mut validator = SchemaValidator::new(); &self.config,
self.enable_code_tools,
for (name, schema) in get_builtin_schemas() {
if let Err(err) = validator.register_schema(&name, schema) {
warn!("Failed to register built-in schema {name}: {err}");
}
}
if self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "web_search")
&& self.config.tools.web_search.enabled
&& self.config.privacy.enable_remote_search
{
let tool = WebSearchTool::new(
self.consent_manager.clone(), self.consent_manager.clone(),
self.credential_manager.clone(), self.credential_manager.clone(),
self.vault.clone(), self.vault.clone(),
); )?;
let schema = tool.schema(); self.tool_registry = registry;
if let Err(err) = validator.register_schema(tool.name(), schema) { self.schema_validator = validator;
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Register web_search_detailed tool (provides snippets) self.mcp_client = match self.config.mcp.mode {
if self McpMode::Legacy => Arc::new(LocalMcpClient::new(
.config self.tool_registry.clone(),
.security self.schema_validator.clone(),
.allowed_tools )),
.iter() McpMode::Enabled => Arc::new(RemoteMcpClient {}),
.any(|tool| tool == "web_search") // Same permission as web_search };
&& self.config.tools.web_search.enabled
&& self.config.privacy.enable_remote_search
{
let tool = WebSearchDetailedTool::new(
self.consent_manager.clone(),
self.credential_manager.clone(),
self.vault.clone(),
);
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
// Code execution tool - only available in code client
if self.enable_code_tools
&& self
.config
.security
.allowed_tools
.iter()
.any(|tool| tool == "code_exec")
&& self.config.tools.code_exec.enabled
{
let tool = CodeExecTool::new(self.config.tools.code_exec.allowed_languages.clone());
let schema = tool.schema();
if let Err(err) = validator.register_schema(tool.name(), schema) {
warn!("Failed to register schema for {}: {err}", tool.name());
}
registry.register(tool);
}
self.tool_registry = Arc::new(registry);
self.schema_validator = Arc::new(validator);
Ok(()) Ok(())
} }
@@ -592,10 +633,13 @@ impl SessionController {
// Execute each tool call // Execute each tool call
if let Some(tool_calls) = &response.message.tool_calls { if let Some(tool_calls) = &response.message.tool_calls {
for tool_call in tool_calls { for tool_call in tool_calls {
let tool_result = self let mcp_tool_call = McpToolCall {
.tool_registry name: tool_call.name.clone(),
.execute(&tool_call.name, tool_call.arguments.clone()) arguments: tool_call.arguments.clone(),
.await; };
let tool_result =
self.mcp_client.call_tool(mcp_tool_call).await;
let tool_response_content = match tool_result { let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output) Ok(result) => serde_json::to_string_pretty(&result.output)
@@ -694,10 +738,11 @@ impl SessionController {
) -> Result<SessionOutcome> { ) -> Result<SessionOutcome> {
// Execute each tool call // Execute each tool call
for tool_call in &tool_calls { for tool_call in &tool_calls {
let tool_result = self let mcp_tool_call = McpToolCall {
.tool_registry name: tool_call.name.clone(),
.execute(&tool_call.name, tool_call.arguments.clone()) arguments: tool_call.arguments.clone(),
.await; };
let tool_result = self.mcp_client.call_tool(mcp_tool_call).await;
let tool_response_content = match tool_result { let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output) Ok(result) => serde_json::to_string_pretty(&result.output)