4 Commits

Author SHA1 Message Date
d002d35bde feat(theme): add tool_output color to themes
- Added a `tool_output` color to the `Theme` struct.
- Updated all built-in themes to include the new color.
- Modified the TUI to use the `tool_output` color for rendering tool output.
2025-10-06 22:18:17 +02:00
c9c3d17db0 feat(theme): add tool_output color to themes
- Added a `tool_output` color to the `Theme` struct.
- Updated all built-in themes to include the new color.
- Modified the TUI to use the `tool_output` color for rendering tool output.
2025-10-06 21:59:08 +02:00
a909455f97 feat(theme): add tool_output color to themes
- Added a `tool_output` color to the `Theme` struct.
- Updated all built-in themes to include the new color.
- Modified the TUI to use the `tool_output` color for rendering tool output.
2025-10-06 21:43:31 +02:00
67381b02db 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.
2025-10-06 20:03:01 +02:00
27 changed files with 691 additions and 118 deletions

View File

@@ -5,6 +5,7 @@ members = [
"crates/owlen-tui",
"crates/owlen-cli",
"crates/owlen-ollama",
"crates/owlen-mcp-server",
]
exclude = []

View File

@@ -39,6 +39,7 @@ sqlx = { workspace = true }
duckduckgo = "0.2.0"
reqwest = { workspace = true, features = ["default"] }
reqwest_011 = { version = "0.11", package = "reqwest" }
path-clean = "1.0"
[dev-dependencies]
tokio-test = { workspace = true }

View File

@@ -14,6 +14,9 @@ pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
pub struct Config {
/// General application settings
pub general: GeneralSettings,
/// MCP (Multi-Client-Provider) settings
#[serde(default)]
pub mcp: McpSettings,
/// Provider specific configuration keyed by provider name
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
@@ -48,6 +51,7 @@ impl Default for Config {
Self {
general: GeneralSettings::default(),
mcp: McpSettings::default(),
providers,
ui: UiSettings::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
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacySettings {

View File

@@ -91,6 +91,11 @@ impl MessageFormatter {
Some(thinking)
};
// If the result is empty but we have thinking content, show a placeholder
if result.trim().is_empty() && thinking_result.is_some() {
result.push_str("[Thinking...]");
}
(result, thinking_result)
}
}

View File

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

View File

@@ -0,0 +1,39 @@
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;
impl RemoteMcpClient {
pub fn new() -> Result<Self> {
Ok(Self)
}
}
#[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::validation::SchemaValidator;
use crate::Result;
use async_trait::async_trait;
use client::McpClient;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
pub mod client;
/// Descriptor for a tool exposed over MCP
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDescriptor {
@@ -80,3 +84,26 @@ impl McpServer {
fn duration_to_millis(duration: Duration) -> u128 {
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

@@ -17,7 +17,7 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
/// use std::sync::Arc;
/// use futures::Stream;
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message};
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message, Role, ChatParameters};
/// use owlen_core::Result;
///
/// // 1. Create a mock provider
@@ -31,18 +31,23 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
///
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
/// Ok(vec![ModelInfo {
/// id: "mock-model".to_string(),
/// provider: "mock".to_string(),
/// name: "mock-model".to_string(),
/// ..Default::default()
/// description: None,
/// context_window: None,
/// capabilities: vec![],
/// supports_tools: false,
/// }])
/// }
///
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
/// Ok(ChatResponse {
/// model: request.model,
/// message: Message { role: "assistant".to_string(), content, ..Default::default() },
/// ..Default::default()
/// message: Message::new(Role::Assistant, content),
/// usage: None,
/// is_streaming: false,
/// is_final: true,
/// })
/// }
///
@@ -67,8 +72,9 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
///
/// let request = ChatRequest {
/// model: "mock-model".to_string(),
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }],
/// ..Default::default()
/// messages: vec![Message::new(Role::User, "Hello".to_string())],
/// parameters: ChatParameters::default(),
/// tools: None,
/// };
///
/// let response = provider.chat(request).await.unwrap();

View File

@@ -1,16 +1,22 @@
use crate::config::Config;
use crate::config::{Config, McpMode};
use crate::consent::ConsentManager;
use crate::conversation::ConversationManager;
use crate::credentials::CredentialManager;
use crate::encryption::{self, VaultHandle};
use crate::formatting::MessageFormatter;
use crate::input::InputBuffer;
use crate::mcp::client::{McpClient, RemoteMcpClient};
use crate::mcp::{LocalMcpClient, McpToolCall};
use crate::model::ModelManager;
use crate::provider::{ChatStream, Provider};
use crate::storage::{SessionMeta, StorageManager};
use crate::tools::{
code_exec::CodeExecTool, registry::ToolRegistry, web_search::WebSearchTool,
web_search_detailed::WebSearchDetailedTool, Tool,
code_exec::CodeExecTool,
fs_tools::{ResourcesGetTool, ResourcesListTool},
registry::ToolRegistry,
web_search::WebSearchTool,
web_search_detailed::WebSearchDetailedTool,
Tool,
};
use crate::types::{
ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall,
@@ -46,7 +52,7 @@ pub enum SessionOutcome {
/// use owlen_core::provider::{Provider, ChatStream};
/// use owlen_core::session::{SessionController, SessionOutcome};
/// use owlen_core::storage::StorageManager;
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo};
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo, Role};
/// use owlen_core::Result;
///
/// // Mock provider for the example
@@ -57,9 +63,10 @@ pub enum SessionOutcome {
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// Ok(ChatResponse {
/// model: request.model,
/// message: Message::assistant("Hello back!".to_string()),
/// ..Default::default()
/// usage: None,
/// is_streaming: false,
/// is_final: true,
/// })
/// }
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
@@ -102,6 +109,7 @@ pub struct SessionController {
consent_manager: Arc<Mutex<ConsentManager>>,
tool_registry: Arc<ToolRegistry>,
schema_validator: Arc<SchemaValidator>,
mcp_client: Arc<dyn McpClient>,
storage: Arc<StorageManager>,
vault: Option<Arc<Mutex<VaultHandle>>>,
master_key: Option<Arc<Vec<u8>>>,
@@ -109,6 +117,90 @@ pub struct SessionController {
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);
}
let resources_list_tool = ResourcesListTool;
let resources_get_tool = ResourcesGetTool;
validator.register_schema(resources_list_tool.name(), resources_list_tool.schema())?;
validator.register_schema(resources_get_tool.name(), resources_get_tool.schema())?;
registry.register(resources_list_tool);
registry.register(resources_get_tool);
Ok((Arc::new(registry), Arc::new(validator)))
}
impl SessionController {
/// Create a new controller with the given provider and configuration
///
@@ -175,7 +267,23 @@ impl SessionController {
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::new()?),
};
let controller = Self {
provider,
conversation,
model_manager,
@@ -183,8 +291,9 @@ impl SessionController {
formatter,
config,
consent_manager,
tool_registry: Arc::new(ToolRegistry::new()),
schema_validator: Arc::new(SchemaValidator::new()),
tool_registry,
schema_validator,
mcp_client,
storage,
vault: vault_handle,
master_key,
@@ -192,8 +301,6 @@ impl SessionController {
enable_code_tools,
};
controller.rebuild_tools()?;
Ok(controller)
}
@@ -415,79 +522,45 @@ impl SessionController {
self.credential_manager.as_ref().map(Arc::clone)
}
pub async fn read_file(&self, path: &str) -> Result<String> {
let call = McpToolCall {
name: "resources/get".to_string(),
arguments: serde_json::json!({ "path": path }),
};
let response = self.mcp_client.call_tool(call).await?;
let content: String = serde_json::from_value(response.output)?;
Ok(content)
}
pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
let call = McpToolCall {
name: "resources/list".to_string(),
arguments: serde_json::json!({ "path": path }),
};
let response = self.mcp_client.call_tool(call).await?;
let content: Vec<String> = serde_json::from_value(response.output)?;
Ok(content)
}
fn rebuild_tools(&mut self) -> Result<()> {
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 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(
let (registry, validator) = build_tools(
&self.config,
self.enable_code_tools,
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);
}
)?;
self.tool_registry = registry;
self.schema_validator = validator;
// Register web_search_detailed tool (provides snippets)
if self
.config
.security
.allowed_tools
.iter()
.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);
}
self.mcp_client = match self.config.mcp.mode {
McpMode::Legacy => Arc::new(LocalMcpClient::new(
self.tool_registry.clone(),
self.schema_validator.clone(),
)),
McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
};
// 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(())
}
@@ -592,10 +665,13 @@ impl SessionController {
// Execute each tool call
if let Some(tool_calls) = &response.message.tool_calls {
for tool_call in tool_calls {
let tool_result = self
.tool_registry
.execute(&tool_call.name, tool_call.arguments.clone())
.await;
let mcp_tool_call = McpToolCall {
name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(),
};
let tool_result =
self.mcp_client.call_tool(mcp_tool_call).await;
let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output)
@@ -694,10 +770,11 @@ impl SessionController {
) -> Result<SessionOutcome> {
// Execute each tool call
for tool_call in &tool_calls {
let tool_result = self
.tool_registry
.execute(&tool_call.name, tool_call.arguments.clone())
.await;
let mcp_tool_call = McpToolCall {
name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(),
};
let tool_result = self.mcp_client.call_tool(mcp_tool_call).await;
let tool_response_content = match tool_result {
Ok(result) => serde_json::to_string_pretty(&result.output)
@@ -748,7 +825,7 @@ impl SessionController {
let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!(
"{}{}",
"{}{} ",
preview,
if first_msg.content.len() > 50 {
"..."
@@ -810,7 +887,7 @@ impl SessionController {
let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!(
"{}{}",
"{}{} ",
preview,
if first_msg.content.len() > 50 {
"..."
@@ -822,7 +899,8 @@ impl SessionController {
// Truncate if too long
let truncated = if description.len() > 100 {
format!("{}...", description.chars().take(97).collect::<String>())
description.chars().take(97).collect::<String>()
// Removed trailing '...' as it's already handled by the format! macro
} else {
description
};
@@ -833,7 +911,7 @@ impl SessionController {
let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>();
Ok(format!(
"{}{}",
"{}{} ",
preview,
if first_msg.content.len() > 50 {
"..."

View File

@@ -44,6 +44,11 @@ pub struct Theme {
#[serde(serialize_with = "serialize_color")]
pub assistant_message_role: Color,
/// Color for tool output messages
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
pub tool_output: Color,
/// Color for thinking panel title
#[serde(deserialize_with = "deserialize_color")]
#[serde(serialize_with = "serialize_color")]
@@ -268,6 +273,7 @@ fn default_dark() -> Theme {
unfocused_panel_border: Color::Rgb(95, 20, 135),
user_message_role: Color::LightBlue,
assistant_message_role: Color::Yellow,
tool_output: Color::Gray,
thinking_panel_title: Color::LightMagenta,
command_bar_background: Color::Black,
status_background: Color::Black,
@@ -297,6 +303,7 @@ fn default_light() -> Theme {
unfocused_panel_border: Color::Rgb(221, 221, 221),
user_message_role: Color::Rgb(0, 85, 164),
assistant_message_role: Color::Rgb(142, 68, 173),
tool_output: Color::Gray,
thinking_panel_title: Color::Rgb(142, 68, 173),
command_bar_background: Color::White,
status_background: Color::White,
@@ -326,6 +333,7 @@ fn gruvbox() -> Theme {
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
tool_output: Color::Rgb(146, 131, 116),
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
status_background: Color::Rgb(60, 56, 54),
@@ -355,6 +363,7 @@ fn dracula() -> Theme {
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
tool_output: Color::Rgb(98, 114, 164),
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
command_bar_background: Color::Rgb(68, 71, 90),
status_background: Color::Rgb(68, 71, 90),
@@ -384,6 +393,7 @@ fn solarized() -> Theme {
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
tool_output: Color::Rgb(101, 123, 131),
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
command_bar_background: Color::Rgb(7, 54, 66),
status_background: Color::Rgb(7, 54, 66),
@@ -413,6 +423,7 @@ fn midnight_ocean() -> Theme {
unfocused_panel_border: Color::Rgb(48, 54, 61),
user_message_role: Color::Rgb(121, 192, 255),
assistant_message_role: Color::Rgb(137, 221, 255),
tool_output: Color::Rgb(84, 110, 122),
thinking_panel_title: Color::Rgb(158, 206, 106),
command_bar_background: Color::Rgb(22, 27, 34),
status_background: Color::Rgb(22, 27, 34),
@@ -442,6 +453,7 @@ fn rose_pine() -> Theme {
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
tool_output: Color::Rgb(110, 106, 134),
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
command_bar_background: Color::Rgb(38, 35, 58),
status_background: Color::Rgb(38, 35, 58),
@@ -471,6 +483,7 @@ fn monokai() -> Theme {
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
tool_output: Color::Rgb(117, 113, 94),
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
command_bar_background: Color::Rgb(39, 40, 34),
status_background: Color::Rgb(39, 40, 34),
@@ -500,6 +513,7 @@ fn material_dark() -> Theme {
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
tool_output: Color::Rgb(84, 110, 122),
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
command_bar_background: Color::Rgb(33, 43, 48),
status_background: Color::Rgb(33, 43, 48),
@@ -529,6 +543,7 @@ fn material_light() -> Theme {
unfocused_panel_border: Color::Rgb(176, 190, 197),
user_message_role: Color::Rgb(68, 138, 255),
assistant_message_role: Color::Rgb(124, 77, 255),
tool_output: Color::Rgb(144, 164, 174),
thinking_panel_title: Color::Rgb(245, 124, 0),
command_bar_background: Color::Rgb(255, 255, 255),
status_background: Color::Rgb(255, 255, 255),

View File

@@ -0,0 +1,111 @@
use crate::tools::{Tool, ToolResult};
use anyhow::Result;
use async_trait::async_trait;
use path_clean::PathClean;
use serde::Deserialize;
use serde_json::json;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Deserialize)]
struct FileArgs {
path: String,
}
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf> {
let path = Path::new(path);
let path = if path.is_absolute() {
path.strip_prefix("/")
.map_err(|_| anyhow::anyhow!("Invalid path"))?
.to_path_buf()
} else {
path.to_path_buf()
};
let full_path = root.join(path).clean();
if !full_path.starts_with(root) {
return Err(anyhow::anyhow!("Path traversal detected"));
}
Ok(full_path)
}
pub struct ResourcesListTool;
#[async_trait]
impl Tool for ResourcesListTool {
fn name(&self) -> &'static str {
"resources/list"
}
fn description(&self) -> &'static str {
"Lists directory contents."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the directory to list."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: FileArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
let entries = fs::read_dir(full_path)?;
let mut result = Vec::new();
for entry in entries {
let entry = entry?;
result.push(entry.file_name().to_string_lossy().to_string());
}
Ok(ToolResult::success(serde_json::to_value(result)?))
}
}
pub struct ResourcesGetTool;
#[async_trait]
impl Tool for ResourcesGetTool {
fn name(&self) -> &'static str {
"resources/get"
}
fn description(&self) -> &'static str {
"Reads file content."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to read."
}
},
"required": ["path"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
let args: FileArgs = serde_json::from_value(args)?;
let root = env::current_dir()?;
let full_path = sanitize_path(&args.path, &root)?;
let content = fs::read_to_string(full_path)?;
Ok(ToolResult::success(serde_json::to_value(content)?))
}
}

View File

@@ -5,6 +5,7 @@ use async_trait::async_trait;
use serde_json::Value;
pub mod code_exec;
pub mod fs_tools;
pub mod registry;
pub mod web_search;
pub mod web_search_detailed;

View File

@@ -0,0 +1,11 @@
[package]
name = "owlen-mcp-server"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
path-clean = "1.0"

View File

@@ -0,0 +1,180 @@
use path_clean::PathClean;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
#[derive(Debug, Deserialize)]
struct Request {
id: u64,
method: String,
params: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct Response {
id: u64,
result: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
id: u64,
error: JsonRpcError,
}
#[derive(Debug, Serialize)]
struct JsonRpcError {
code: i64,
message: String,
}
#[derive(Deserialize)]
struct FileArgs {
path: String,
}
async fn handle_request(req: Request, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
match req.method.as_str() {
"resources/list" => {
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
code: -32602,
message: format!("Invalid params: {}", e),
})?;
resources_list(&args.path, root).await
}
"resources/get" => {
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
code: -32602,
message: format!("Invalid params: {}", e),
})?;
resources_get(&args.path, root).await
}
_ => Err(JsonRpcError {
code: -32601,
message: "Method not found".to_string(),
}),
}
}
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
let path = Path::new(path);
let path = if path.is_absolute() {
path.strip_prefix("/")
.map_err(|_| JsonRpcError {
code: -32602,
message: "Invalid path".to_string(),
})?
.to_path_buf()
} else {
path.to_path_buf()
};
let full_path = root.join(path).clean();
if !full_path.starts_with(root) {
return Err(JsonRpcError {
code: -32602,
message: "Path traversal detected".to_string(),
});
}
Ok(full_path)
}
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
let full_path = sanitize_path(path, root)?;
let entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read directory: {}", e),
})?;
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read directory entry: {}", e),
})?;
result.push(entry.file_name().to_string_lossy().to_string());
}
Ok(serde_json::json!(result))
}
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
let full_path = sanitize_path(path, root)?;
let content = fs::read_to_string(full_path).map_err(|e| JsonRpcError {
code: -32000,
message: format!("Failed to read file: {}", e),
})?;
Ok(serde_json::json!(content))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?;
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => {
// EOF
break;
}
Ok(_) => {
let req: Request = match serde_json::from_str(&line) {
Ok(req) => req,
Err(e) => {
let err_resp = ErrorResponse {
id: 0,
error: JsonRpcError {
code: -32700,
message: format!("Parse error: {}", e),
},
};
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
continue;
}
};
let request_id = req.id;
match handle_request(req, &root).await {
Ok(result) => {
let resp = Response {
id: request_id,
result,
};
let resp_str = serde_json::to_string(&resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
}
Err(error) => {
let err_resp = ErrorResponse {
id: request_id,
error,
};
let resp_str = serde_json::to_string(&err_resp)?;
stdout.write_all(resp_str.as_bytes()).await?;
stdout.write_all(b"\n").await?;
}
}
}
Err(e) => {
// Handle read error
eprintln!("Error reading from stdin: {}", e);
break;
}
}
}
Ok(())
}

View File

@@ -956,21 +956,21 @@ mod tests {
#[test]
fn resolve_api_key_expands_braced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY", "super-secret");
std::env::set_var("OWLEN_TEST_KEY_BRACED", "super-secret");
assert_eq!(
resolve_api_key(Some("${OWLEN_TEST_KEY}".into())),
resolve_api_key(Some("${OWLEN_TEST_KEY_BRACED}".into())),
Some("super-secret".into())
);
std::env::remove_var("OWLEN_TEST_KEY");
std::env::remove_var("OWLEN_TEST_KEY_BRACED");
}
#[test]
fn resolve_api_key_expands_unbraced_env_reference() {
std::env::set_var("OWLEN_TEST_KEY", "another-secret");
std::env::set_var("OWLEN_TEST_KEY_UNBRACED", "another-secret");
assert_eq!(
resolve_api_key(Some("$OWLEN_TEST_KEY".into())),
resolve_api_key(Some("$OWLEN_TEST_KEY_UNBRACED".into())),
Some("another-secret".into())
);
std::env::remove_var("OWLEN_TEST_KEY");
std::env::remove_var("OWLEN_TEST_KEY_UNBRACED");
}
}

View File

@@ -353,7 +353,6 @@ impl ChatApp {
("open", "Alias for load"),
("o", "Alias for load"),
("sessions", "List saved sessions"),
("ls", "Alias for sessions"),
("help", "Show help documentation"),
("h", "Alias for help"),
("model", "Select a model"),
@@ -363,6 +362,9 @@ impl ChatApp {
("theme", "Switch theme"),
("themes", "List available themes"),
("reload", "Reload configuration and themes"),
("e", "Edit a file"),
("edit", "Alias for edit"),
("ls", "List directory contents"),
("privacy-enable", "Enable a privacy-sensitive tool"),
("privacy-disable", "Disable a privacy-sensitive tool"),
("privacy-clear", "Clear stored secure data"),
@@ -1385,7 +1387,7 @@ impl ChatApp {
}
}
}
"sessions" | "ls" => {
"sessions" => {
// List saved sessions
match self.controller.list_saved_sessions().await {
Ok(sessions) => {
@@ -1419,6 +1421,47 @@ impl ChatApp {
self.controller.start_new_conversation(None, None);
self.status = "Started new conversation".to_string();
}
"e" | "edit" => {
if let Some(path) = args.first() {
match self.controller.read_file(path).await {
Ok(content) => {
let message = format!(
"The content of file `{}` is:\n```\n{}\n```",
path, content
);
self.controller
.conversation_mut()
.push_user_message(message);
self.pending_llm_request = true;
}
Err(e) => {
self.error =
Some(format!("Failed to read file: {}", e));
}
}
} else {
self.error = Some("Usage: :e <path>".to_string());
}
}
"ls" => {
let path = args.first().copied().unwrap_or(".");
match self.controller.list_dir(path).await {
Ok(entries) => {
let message = format!(
"Directory listing for `{}`:\n```\n{}\n```",
path,
entries.join("\n")
);
self.controller
.conversation_mut()
.push_user_message(message);
}
Err(e) => {
self.error =
Some(format!("Failed to list directory: {}", e));
}
}
}
"theme" => {
if args.is_empty() {
self.error = Some("Usage: :theme <name>".to_string());

View File

@@ -670,7 +670,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let mut spans = vec![Span::raw(format!("{indent}{}", seg))];
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(format!("{indent}{}", seg), style)];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
@@ -682,7 +688,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
let chunks = wrap(&content, content_width as usize);
let chunks_len = chunks.len();
for (i, seg) in chunks.into_iter().enumerate() {
let mut spans = vec![Span::raw(seg.into_owned())];
let style = if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
};
let mut spans = vec![Span::styled(seg.into_owned(), style)];
if i == chunks_len - 1 && is_streaming {
spans.push(Span::styled("", Style::default().fg(theme.cursor)));
}
@@ -2075,7 +2087,7 @@ fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
match role {
Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.info),
Role::System => Style::default().fg(theme.unfocused_panel_border),
Role::Tool => Style::default().fg(theme.info),
}
}
@@ -2085,14 +2097,17 @@ fn format_tool_output(content: &str) -> String {
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
let mut output = String::new();
let mut content_found = false;
// Extract query if present
if let Some(query) = json.get("query").and_then(|v| v.as_str()) {
output.push_str(&format!("Query: \"{}\"\n\n", query));
content_found = true;
}
// Extract results array
if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
content_found = true;
if results.is_empty() {
output.push_str("No results found");
return output;
@@ -2158,12 +2173,20 @@ fn format_tool_output(content: &str) -> String {
if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) {
output.push_str(&format!("Found {} result(s)", total));
}
} else if let Some(result) = json.get("result").and_then(|v| v.as_str()) {
content_found = true;
output.push_str(result);
} else if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
content_found = true;
// Handle error results
output.push_str(&format!("❌ Error: {}", error));
}
if content_found {
output
} else {
content.to_string()
}
} else {
// If not JSON, return as-is
content.to_string()

View File

@@ -5,6 +5,7 @@ focused_panel_border = "lightmagenta"
unfocused_panel_border = "#5f1487"
user_message_role = "lightblue"
assistant_message_role = "yellow"
tool_output = "gray"
thinking_panel_title = "lightmagenta"
command_bar_background = "black"
status_background = "black"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#4a90e2"
unfocused_panel_border = "#dddddd"
user_message_role = "#0055a4"
assistant_message_role = "#8e44ad"
tool_output = "gray"
thinking_panel_title = "#8e44ad"
command_bar_background = "white"
status_background = "white"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#ff79c6"
unfocused_panel_border = "#44475a"
user_message_role = "#8be9fd"
assistant_message_role = "#ff79c6"
tool_output = "#6272a4"
thinking_panel_title = "#bd93f9"
command_bar_background = "#44475a"
status_background = "#44475a"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#fe8019"
unfocused_panel_border = "#7c6f64"
user_message_role = "#b8bb26"
assistant_message_role = "#83a598"
tool_output = "#928374"
thinking_panel_title = "#d3869b"
command_bar_background = "#3c3836"
status_background = "#3c3836"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#80cbc4"
unfocused_panel_border = "#546e7a"
user_message_role = "#82aaff"
assistant_message_role = "#c792ea"
tool_output = "#546e7a"
thinking_panel_title = "#ffcb6b"
command_bar_background = "#212b30"
status_background = "#212b30"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#009688"
unfocused_panel_border = "#b0bec5"
user_message_role = "#448aff"
assistant_message_role = "#7c4dff"
tool_output = "#90a4ae"
thinking_panel_title = "#f57c00"
command_bar_background = "#ffffff"
status_background = "#ffffff"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#58a6ff"
unfocused_panel_border = "#30363d"
user_message_role = "#79c0ff"
assistant_message_role = "#89ddff"
tool_output = "#546e7a"
thinking_panel_title = "#9ece6a"
command_bar_background = "#161b22"
status_background = "#161b22"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#f92672"
unfocused_panel_border = "#75715e"
user_message_role = "#66d9ef"
assistant_message_role = "#ae81ff"
tool_output = "#75715e"
thinking_panel_title = "#e6db74"
command_bar_background = "#272822"
status_background = "#272822"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#eb6f92"
unfocused_panel_border = "#26233a"
user_message_role = "#31748f"
assistant_message_role = "#9ccfd8"
tool_output = "#6e6a86"
thinking_panel_title = "#c4a7e7"
command_bar_background = "#26233a"
status_background = "#26233a"

View File

@@ -5,6 +5,7 @@ focused_panel_border = "#268bd2"
unfocused_panel_border = "#073642"
user_message_role = "#2aa198"
assistant_message_role = "#cb4b16"
tool_output = "#657b83"
thinking_panel_title = "#6c71c4"
command_bar_background = "#073642"
status_background = "#073642"