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:
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
33
crates/owlen-core/src/mcp/client.rs
Normal file
33
crates/owlen-core/src/mcp/client.rs
Normal 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user