Compare commits
5 Commits
4c066bf2da
...
5e81185df3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e81185df3 | |||
| 7534c9ef8d | |||
| 9545a4b3ad | |||
| e94df2c48a | |||
| cdf95002fc |
@@ -8,6 +8,8 @@ members = [
|
||||
"crates/owlen-mcp-server",
|
||||
"crates/owlen-mcp-llm-server",
|
||||
"crates/owlen-mcp-client",
|
||||
"crates/owlen-mcp-code-server",
|
||||
"crates/owlen-mcp-prompt-server",
|
||||
]
|
||||
exclude = []
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ required-features = ["chat-client"]
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-ollama = { path = "../owlen-ollama" }
|
||||
# Optional TUI dependency, enabled by the "chat-client" feature.
|
||||
owlen-tui = { path = "../owlen-tui", optional = true }
|
||||
|
||||
|
||||
@@ -11,11 +11,15 @@ use std::sync::Arc;
|
||||
use clap::Parser;
|
||||
use owlen_cli::agent::{AgentConfig, AgentExecutor};
|
||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||
use owlen_ollama::OllamaProvider;
|
||||
|
||||
/// Command‑line arguments for the agent binary.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "owlen-agent", author, version, about = "Run the ReAct agent")]
|
||||
#[command(
|
||||
name = "owlen-agent",
|
||||
author,
|
||||
version,
|
||||
about = "Run the ReAct agent via MCP"
|
||||
)]
|
||||
struct Args {
|
||||
/// The initial user query.
|
||||
prompt: String,
|
||||
@@ -31,11 +35,13 @@ struct Args {
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialise the LLM provider (Ollama) – uses default local URL.
|
||||
let provider = Arc::new(OllamaProvider::new("http://localhost:11434")?);
|
||||
// Initialise the MCP client (remote LLM server) – this client also knows how
|
||||
// to call the built‑in resource tools.
|
||||
let mcp_client = Arc::new(RemoteMcpClient::new()?);
|
||||
// Initialise the MCP LLM client – it implements Provider and talks to the
|
||||
// MCP LLM server which wraps Ollama. This ensures all communication goes
|
||||
// through the MCP architecture (Phase 10 requirement).
|
||||
let provider = Arc::new(RemoteMcpClient::new()?);
|
||||
|
||||
// The MCP client also serves as the tool client for resource operations
|
||||
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||
|
||||
let config = AgentConfig {
|
||||
max_iterations: args.max_iter,
|
||||
@@ -43,10 +49,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
..AgentConfig::default()
|
||||
};
|
||||
|
||||
let executor = AgentExecutor::new(provider, mcp_client, config, None);
|
||||
let executor = AgentExecutor::new(provider, mcp_client, config);
|
||||
match executor.run(args.prompt).await {
|
||||
Ok(answer) => {
|
||||
println!("\nFinal answer:\n{}", answer);
|
||||
Ok(result) => {
|
||||
println!("\n✓ Agent completed in {} iterations", result.iterations);
|
||||
println!("\nFinal answer:\n{}", result.answer);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!(e)),
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use owlen_core::{mode::Mode, session::SessionController, storage::StorageManager};
|
||||
use owlen_ollama::OllamaProvider;
|
||||
use owlen_core::{
|
||||
mcp::remote_client::RemoteMcpClient, mode::Mode, session::SessionController,
|
||||
storage::StorageManager, Provider,
|
||||
};
|
||||
use owlen_tui::tui_controller::{TuiController, TuiRequest};
|
||||
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
||||
use std::io;
|
||||
@@ -21,7 +23,7 @@ use ratatui::{prelude::CrosstermBackend, Terminal};
|
||||
/// Owlen - Terminal UI for LLM chat
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "owlen")]
|
||||
#[command(about = "Terminal UI for LLM chat with Ollama", long_about = None)]
|
||||
#[command(about = "Terminal UI for LLM chat via MCP", long_about = None)]
|
||||
struct Args {
|
||||
/// Start in code mode (enables all tools)
|
||||
#[arg(long, short = 'c')]
|
||||
@@ -44,21 +46,16 @@ async fn main() -> Result<()> {
|
||||
let mut cfg = config::try_load_config().unwrap_or_default();
|
||||
// Disable encryption for CLI to avoid password prompts in this environment.
|
||||
cfg.privacy.encrypt_local_data = false;
|
||||
// Determine provider configuration
|
||||
let provider_name = cfg.general.default_provider.clone();
|
||||
let provider_cfg = config::ensure_provider_config(&mut cfg, &provider_name).clone();
|
||||
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
|
||||
if provider_type != "ollama" && provider_type != "ollama-cloud" {
|
||||
anyhow::bail!(
|
||||
"Unsupported provider type '{}' configured for provider '{}'",
|
||||
provider_cfg.provider_type,
|
||||
provider_name,
|
||||
);
|
||||
}
|
||||
let provider = Arc::new(OllamaProvider::from_config(
|
||||
&provider_cfg,
|
||||
Some(&cfg.general),
|
||||
)?);
|
||||
|
||||
// Create MCP LLM client as the provider (replaces direct OllamaProvider usage)
|
||||
let provider: Arc<dyn Provider> = if let Some(mcp_server) = cfg.mcp_servers.first() {
|
||||
// Use configured MCP server if available
|
||||
Arc::new(RemoteMcpClient::new_with_config(mcp_server)?)
|
||||
} else {
|
||||
// Fall back to default MCP LLM server discovery
|
||||
Arc::new(RemoteMcpClient::new()?)
|
||||
};
|
||||
|
||||
let storage = Arc::new(StorageManager::new().await?);
|
||||
let controller =
|
||||
SessionController::new(provider, cfg, storage.clone(), tui_controller, false).await?;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
use owlen_cli::agent::{AgentConfig, AgentExecutor, LlmResponse};
|
||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||
use owlen_ollama::OllamaProvider;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -72,11 +71,11 @@ async fn test_react_parsing_with_multiline_thought() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires Ollama to be running
|
||||
#[ignore] // Requires MCP LLM server to be running
|
||||
async fn test_agent_single_tool_scenario() {
|
||||
// This test requires a running Ollama instance and MCP server
|
||||
let provider = Arc::new(OllamaProvider::new("http://localhost:11434").unwrap());
|
||||
let mcp_client = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
// This test requires a running MCP LLM server (which wraps Ollama)
|
||||
let provider = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||
|
||||
let config = AgentConfig {
|
||||
max_iterations: 5,
|
||||
@@ -109,8 +108,8 @@ async fn test_agent_single_tool_scenario() {
|
||||
#[ignore] // Requires Ollama to be running
|
||||
async fn test_agent_multi_step_workflow() {
|
||||
// Test a query that requires multiple tool calls
|
||||
let provider = Arc::new(OllamaProvider::new("http://localhost:11434").unwrap());
|
||||
let mcp_client = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
let provider = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||
|
||||
let config = AgentConfig {
|
||||
max_iterations: 10,
|
||||
@@ -141,8 +140,8 @@ async fn test_agent_multi_step_workflow() {
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires Ollama
|
||||
async fn test_agent_iteration_limit() {
|
||||
let provider = Arc::new(OllamaProvider::new("http://localhost:11434").unwrap());
|
||||
let mcp_client = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
let provider = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||
|
||||
let config = AgentConfig {
|
||||
max_iterations: 2, // Very low limit to test enforcement
|
||||
@@ -183,8 +182,8 @@ async fn test_agent_iteration_limit() {
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires Ollama
|
||||
async fn test_agent_tool_budget_enforcement() {
|
||||
let provider = Arc::new(OllamaProvider::new("http://localhost:11434").unwrap());
|
||||
let mcp_client = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
let provider = Arc::new(RemoteMcpClient::new().unwrap());
|
||||
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||
|
||||
let config = AgentConfig {
|
||||
max_iterations: 20,
|
||||
@@ -224,12 +223,9 @@ async fn test_agent_tool_budget_enforcement() {
|
||||
// Helper function to create a test executor
|
||||
// For parsing tests, we don't need a real connection
|
||||
fn create_test_executor() -> AgentExecutor {
|
||||
// Create dummy instances - the parse_response method doesn't actually use them
|
||||
let provider = Arc::new(OllamaProvider::new("http://localhost:11434").unwrap());
|
||||
|
||||
// For parsing tests, we can accept the error from RemoteMcpClient::new()
|
||||
// since we're only testing parse_response which doesn't use the MCP client
|
||||
let mcp_client = match RemoteMcpClient::new() {
|
||||
let provider = match RemoteMcpClient::new() {
|
||||
Ok(client) => Arc::new(client),
|
||||
Err(_) => {
|
||||
// If MCP server binary doesn't exist, parsing tests can still run
|
||||
@@ -239,6 +235,8 @@ fn create_test_executor() -> AgentExecutor {
|
||||
}
|
||||
};
|
||||
|
||||
let mcp_client = Arc::clone(&provider) as Arc<RemoteMcpClient>;
|
||||
|
||||
let config = AgentConfig::default();
|
||||
AgentExecutor::new(provider, mcp_client, config, None)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ reqwest = { workspace = true, features = ["default"] }
|
||||
reqwest_011 = { version = "0.11", package = "reqwest" }
|
||||
path-clean = "1.0"
|
||||
tokio-stream = "0.1"
|
||||
tokio-tungstenite = "0.21"
|
||||
tungstenite = "0.21"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -1,377 +1,421 @@
|
||||
//! High‑level agentic executor implementing the ReAct pattern.
|
||||
//! Agentic execution loop with ReAct pattern support.
|
||||
//!
|
||||
//! The executor coordinates three responsibilities:
|
||||
//! 1. Build a ReAct prompt from the conversation history and the list of
|
||||
//! available MCP tools.
|
||||
//! 2. Send the prompt to an LLM provider (any type implementing
|
||||
//! `owlen_core::Provider`).
|
||||
//! 3. Parse the LLM response, optionally invoke a tool via an MCP client,
|
||||
//! and feed the observation back into the conversation.
|
||||
//!
|
||||
//! The implementation is intentionally minimal – it provides the core loop
|
||||
//! required by Phase 4 of the roadmap. Integration with the TUI and additional
|
||||
//! safety mechanisms can be added on top of this module.
|
||||
//! This module provides the core agent orchestration logic that allows an LLM
|
||||
//! to reason about tasks, execute tools, and observe results in an iterative loop.
|
||||
|
||||
use crate::mcp::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::provider::Provider;
|
||||
use crate::types::{ChatParameters, ChatRequest, Message};
|
||||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ui::UiController;
|
||||
/// Maximum number of agent iterations before stopping
|
||||
const DEFAULT_MAX_ITERATIONS: usize = 15;
|
||||
|
||||
use dirs;
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::signal;
|
||||
|
||||
use crate::mcp::client::McpClient;
|
||||
use crate::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::{
|
||||
types::{ChatRequest, Message},
|
||||
Error, Provider, Result as CoreResult,
|
||||
};
|
||||
|
||||
/// Configuration for the agent executor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentConfig {
|
||||
/// Maximum number of ReAct iterations before the executor aborts.
|
||||
pub max_iterations: usize,
|
||||
/// Model name to use for the LLM provider.
|
||||
pub model: String,
|
||||
/// Optional temperature.
|
||||
pub temperature: Option<f32>,
|
||||
/// Optional max_tokens.
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Maximum number of tool calls allowed per execution (budget).
|
||||
pub max_tool_calls: usize,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iterations: 10,
|
||||
model: "ollama".into(),
|
||||
temperature: Some(0.7),
|
||||
max_tokens: None,
|
||||
max_tool_calls: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum representing the possible parsed LLM responses in ReAct format.
|
||||
#[derive(Debug)]
|
||||
/// Parsed response from the LLM in ReAct format
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum LlmResponse {
|
||||
/// A reasoning step without action.
|
||||
Reasoning { thought: String },
|
||||
/// The model wants to invoke a tool.
|
||||
/// LLM wants to execute a tool
|
||||
ToolCall {
|
||||
thought: String,
|
||||
tool_name: String,
|
||||
arguments: serde_json::Value,
|
||||
},
|
||||
/// The model produced a final answer.
|
||||
/// LLM has reached a final answer
|
||||
FinalAnswer { thought: String, answer: String },
|
||||
/// LLM is just reasoning without taking action
|
||||
Reasoning { thought: String },
|
||||
}
|
||||
|
||||
/// Error type for the agent executor.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AgentError {
|
||||
#[error("LLM provider error: {0}")]
|
||||
Provider(Error),
|
||||
#[error("MCP client error: {0}")]
|
||||
Mcp(Error),
|
||||
#[error("Tool execution denied by user")]
|
||||
ToolDenied,
|
||||
#[error("Failed to parse LLM response")]
|
||||
Parse,
|
||||
#[error("Maximum iterations ({0}) reached without final answer")]
|
||||
MaxIterationsReached(usize),
|
||||
#[error("Agent execution cancelled by user (Ctrl+C)")]
|
||||
Cancelled,
|
||||
/// Parse error when LLM response doesn't match expected format
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
#[error("No recognizable pattern found in response")]
|
||||
NoPattern,
|
||||
#[error("Missing required field: {0}")]
|
||||
MissingField(String),
|
||||
#[error("Invalid JSON in ACTION_INPUT: {0}")]
|
||||
InvalidJson(String),
|
||||
}
|
||||
|
||||
/// Core executor handling the ReAct loop.
|
||||
/// Result of an agent execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentResult {
|
||||
/// Final answer from the agent
|
||||
pub answer: String,
|
||||
/// Number of iterations taken
|
||||
pub iterations: usize,
|
||||
/// All messages exchanged during execution
|
||||
pub messages: Vec<Message>,
|
||||
/// Whether the agent completed successfully
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Configuration for agent execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentConfig {
|
||||
/// Maximum number of iterations
|
||||
pub max_iterations: usize,
|
||||
/// Model to use for reasoning
|
||||
pub model: String,
|
||||
/// Temperature for LLM sampling
|
||||
pub temperature: Option<f32>,
|
||||
/// Max tokens per LLM call
|
||||
pub max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iterations: DEFAULT_MAX_ITERATIONS,
|
||||
model: "llama3.2:latest".to_string(),
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(4096),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent executor that orchestrates the ReAct loop
|
||||
pub struct AgentExecutor {
|
||||
llm_client: Arc<dyn Provider + Send + Sync>,
|
||||
tool_client: Arc<dyn McpClient + Send + Sync>,
|
||||
/// LLM provider for reasoning
|
||||
llm_client: Arc<dyn Provider>,
|
||||
/// MCP client for tool execution
|
||||
tool_client: Arc<dyn McpClient>,
|
||||
/// Agent configuration
|
||||
config: AgentConfig,
|
||||
ui_controller: Option<Arc<dyn UiController + Send + Sync>>, // optional UI for confirmations
|
||||
}
|
||||
|
||||
impl AgentExecutor {
|
||||
/// Construct a new executor.
|
||||
/// Create a new agent executor
|
||||
pub fn new(
|
||||
llm_client: Arc<dyn Provider + Send + Sync>,
|
||||
tool_client: Arc<dyn McpClient + Send + Sync>,
|
||||
llm_client: Arc<dyn Provider>,
|
||||
tool_client: Arc<dyn McpClient>,
|
||||
config: AgentConfig,
|
||||
ui_controller: Option<Arc<dyn UiController + Send + Sync>>, // pass None for headless
|
||||
) -> Self {
|
||||
Self {
|
||||
llm_client,
|
||||
tool_client,
|
||||
config,
|
||||
ui_controller,
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover tools exposed by the MCP server.
|
||||
async fn discover_tools(&self) -> CoreResult<Vec<McpToolDescriptor>> {
|
||||
self.tool_client.list_tools().await
|
||||
}
|
||||
/// Run the agent loop with the given query
|
||||
pub async fn run(&self, query: String) -> Result<AgentResult> {
|
||||
let mut messages = vec![Message::user(query)];
|
||||
let tools = self.discover_tools().await?;
|
||||
|
||||
// #[allow(dead_code)]
|
||||
// Build a ReAct prompt from the current message history and discovered tools.
|
||||
/*
|
||||
#[allow(dead_code)]
|
||||
fn build_prompt(
|
||||
&self,
|
||||
history: &[Message],
|
||||
tools: &[McpToolDescriptor],
|
||||
) -> String {
|
||||
// System prompt describing the format.
|
||||
let system = "You are an intelligent agent following the ReAct pattern. Use the following sections:\nTHOUGHT: your reasoning\nACTION: the tool name you want to call (or "final_answer")\nACTION_INPUT: JSON arguments for the tool.\nIf ACTION is "final_answer", provide the final answer in the next line after the ACTION_INPUT.\n";
|
||||
for iteration in 0..self.config.max_iterations {
|
||||
let prompt = self.build_react_prompt(&messages, &tools);
|
||||
let response = self.generate_llm_response(prompt).await?;
|
||||
|
||||
let mut prompt = format!("System: {}\n", system);
|
||||
// Append conversation history.
|
||||
for msg in history {
|
||||
let role = match msg.role {
|
||||
Role::User => "User",
|
||||
Role::Assistant => "Assistant",
|
||||
Role::System => "System",
|
||||
Role::Tool => "Tool",
|
||||
};
|
||||
prompt.push_str(&format!("{}: {}\n", role, msg.content));
|
||||
}
|
||||
// Append tool descriptions.
|
||||
if !tools.is_empty() {
|
||||
let tools_json = json!(tools);
|
||||
prompt.push_str(&format!("Available tools (JSON schema): {}\n", tools_json));
|
||||
}
|
||||
prompt
|
||||
}
|
||||
*/
|
||||
|
||||
// build_prompt removed; not used in current implementation
|
||||
|
||||
/// Parse raw LLM text into a structured `LlmResponse`.
|
||||
pub fn parse_response(&self, text: &str) -> std::result::Result<LlmResponse, AgentError> {
|
||||
// Normalise line endings.
|
||||
let txt = text.trim();
|
||||
// Regex patterns for parsing ReAct format.
|
||||
// THOUGHT and ACTION capture up to the next newline.
|
||||
// ACTION_INPUT captures everything remaining (including multiline JSON).
|
||||
let thought_re = Regex::new(r"(?s)THOUGHT:\s*(?P<thought>.+?)(?:\n|$)").unwrap();
|
||||
let action_re = Regex::new(r"(?s)ACTION:\s*(?P<action>.+?)(?:\n|$)").unwrap();
|
||||
// ACTION_INPUT captures rest of text (multiline-friendly)
|
||||
let input_re = Regex::new(r"(?s)ACTION_INPUT:\s*(?P<input>.+)").unwrap();
|
||||
|
||||
let thought = thought_re
|
||||
.captures(txt)
|
||||
.and_then(|c| c.name("thought"))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.ok_or(AgentError::Parse)?;
|
||||
let action = action_re
|
||||
.captures(txt)
|
||||
.and_then(|c| c.name("action"))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.ok_or(AgentError::Parse)?;
|
||||
let input = input_re
|
||||
.captures(txt)
|
||||
.and_then(|c| c.name("input"))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
.ok_or(AgentError::Parse)?;
|
||||
|
||||
if action.eq_ignore_ascii_case("final_answer") {
|
||||
Ok(LlmResponse::FinalAnswer {
|
||||
thought,
|
||||
answer: input,
|
||||
})
|
||||
} else {
|
||||
// Parse arguments as JSON, falling back to a string if invalid.
|
||||
let args = serde_json::from_str(&input).unwrap_or_else(|_| json!(input));
|
||||
Ok(LlmResponse::ToolCall {
|
||||
thought,
|
||||
tool_name: action,
|
||||
arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a single tool call via the MCP client.
|
||||
async fn execute_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> CoreResult<McpToolResponse> {
|
||||
// For potentially unsafe tools (write/delete) ask for UI confirmation
|
||||
// if a controller is available.
|
||||
let dangerous = name.contains("write") || name.contains("delete");
|
||||
if dangerous {
|
||||
if let Some(controller) = &self.ui_controller {
|
||||
let prompt = format!(
|
||||
"Confirm execution of potentially unsafe tool '{}' with args {}?",
|
||||
name, arguments
|
||||
);
|
||||
if !controller.confirm(&prompt).await {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"Tool '{}' denied by user",
|
||||
name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let call = McpToolCall {
|
||||
name: name.to_string(),
|
||||
arguments,
|
||||
};
|
||||
self.tool_client.call_tool(call).await
|
||||
}
|
||||
|
||||
/// Run the full ReAct loop and return the final answer.
|
||||
pub async fn run(&self, query: String) -> std::result::Result<String, AgentError> {
|
||||
let tools = self.discover_tools().await.map_err(AgentError::Mcp)?;
|
||||
|
||||
// Build system prompt with ReAct format instructions
|
||||
let tools_desc = tools
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let schema_str = serde_json::to_string_pretty(&t.input_schema)
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
format!(
|
||||
"- {}: {}\n Input schema: {}",
|
||||
t.name, t.description, schema_str
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let system_prompt = format!(
|
||||
"You are an AI assistant that uses the ReAct (Reasoning + Acting) pattern to solve tasks.\n\n\
|
||||
You must ALWAYS respond in this exact format:\n\n\
|
||||
THOUGHT: <your reasoning about what to do next>\n\
|
||||
ACTION: <tool_name or \"final_answer\">\n\
|
||||
ACTION_INPUT: <JSON arguments for the tool, or the final answer text>\n\n\
|
||||
Available tools:\n{}\n\n\
|
||||
HOW IT WORKS:\n\
|
||||
1. When you call a tool, you will receive its output in the next message\n\
|
||||
2. After receiving the tool output, analyze it and either:\n\
|
||||
a) Use the information to provide a final answer\n\
|
||||
b) Call another tool if you need more information\n\
|
||||
3. When you have the information needed to answer the user's question, provide a final answer\n\n\
|
||||
To provide a final answer:\n\
|
||||
THOUGHT: <summary of what you learned>\n\
|
||||
ACTION: final_answer\n\
|
||||
ACTION_INPUT: <your complete answer using the information from the tools>\n\n\
|
||||
IMPORTANT: You MUST follow this format exactly. Do not deviate from it.\n\
|
||||
IMPORTANT: Only use the tools listed above. Do not try to use tools that are not listed.\n\
|
||||
IMPORTANT: When providing the final answer, include the actual information you learned, not just the tool arguments.",
|
||||
tools_desc
|
||||
);
|
||||
|
||||
// Initialize conversation with system prompt and user query
|
||||
let mut messages = vec![Message::system(system_prompt.clone()), Message::user(query)];
|
||||
|
||||
// Cancellation flag set when Ctrl+C is received.
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let cancel_flag = cancelled.clone();
|
||||
tokio::spawn(async move {
|
||||
// Wait for Ctrl+C signal.
|
||||
let _ = signal::ctrl_c().await;
|
||||
cancel_flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let mut tool_calls = 0usize;
|
||||
for _ in 0..self.config.max_iterations {
|
||||
if cancelled.load(Ordering::SeqCst) {
|
||||
return Err(AgentError::Cancelled);
|
||||
}
|
||||
// Build a ChatRequest for the provider.
|
||||
let chat_req = ChatRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages: messages.clone(),
|
||||
parameters: crate::types::ChatParameters {
|
||||
temperature: self.config.temperature,
|
||||
max_tokens: self.config.max_tokens,
|
||||
stream: false,
|
||||
extra: Default::default(),
|
||||
},
|
||||
tools: Some(tools.clone()),
|
||||
};
|
||||
let raw_resp = self
|
||||
.llm_client
|
||||
.chat(chat_req)
|
||||
.await
|
||||
.map_err(AgentError::Provider)?;
|
||||
let parsed = self
|
||||
.parse_response(&raw_resp.message.content)
|
||||
.map_err(|e| {
|
||||
eprintln!("\n=== PARSE ERROR ===");
|
||||
eprintln!("Error: {:?}", e);
|
||||
eprintln!("LLM Response:\n{}", raw_resp.message.content);
|
||||
eprintln!("=== END ===\n");
|
||||
e
|
||||
})?;
|
||||
match parsed {
|
||||
LlmResponse::Reasoning { thought } => {
|
||||
// Append the reasoning as an assistant message.
|
||||
messages.push(Message::assistant(thought));
|
||||
}
|
||||
match self.parse_response(&response)? {
|
||||
LlmResponse::ToolCall {
|
||||
thought,
|
||||
tool_name,
|
||||
arguments,
|
||||
} => {
|
||||
// Record the thought.
|
||||
messages.push(Message::assistant(thought));
|
||||
// Enforce tool call budget.
|
||||
tool_calls += 1;
|
||||
if tool_calls > self.config.max_tool_calls {
|
||||
return Err(AgentError::MaxIterationsReached(self.config.max_iterations));
|
||||
}
|
||||
// Execute tool.
|
||||
let args_clone = arguments.clone();
|
||||
let tool_resp = self
|
||||
.execute_tool(&tool_name, args_clone.clone())
|
||||
.await
|
||||
.map_err(AgentError::Mcp)?;
|
||||
// Convert tool output to a string for the message.
|
||||
let output_str = tool_resp
|
||||
.output
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| tool_resp.output.to_string());
|
||||
// Audit log the tool execution.
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
let log_path = config_dir.join("owlen/logs/tool_execution.log");
|
||||
if let Some(parent) = log_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(mut file) =
|
||||
OpenOptions::new().create(true).append(true).open(&log_path)
|
||||
{
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"{} | tool: {} | args: {} | output: {}",
|
||||
ts, tool_name, args_clone, output_str
|
||||
);
|
||||
}
|
||||
}
|
||||
messages.push(Message::tool(tool_name, output_str));
|
||||
// Add assistant's reasoning
|
||||
messages.push(Message::assistant(format!(
|
||||
"THOUGHT: {}\nACTION: {}\nACTION_INPUT: {}",
|
||||
thought,
|
||||
tool_name,
|
||||
serde_json::to_string_pretty(&arguments).unwrap_or_default()
|
||||
)));
|
||||
|
||||
// Execute the tool
|
||||
let result = self.execute_tool(&tool_name, arguments).await?;
|
||||
|
||||
// Add observation
|
||||
messages.push(Message::tool(
|
||||
tool_name.clone(),
|
||||
format!(
|
||||
"OBSERVATION: {}",
|
||||
serde_json::to_string_pretty(&result.output).unwrap_or_default()
|
||||
),
|
||||
));
|
||||
}
|
||||
LlmResponse::FinalAnswer { thought, answer } => {
|
||||
// Append final thought and answer, then return.
|
||||
messages.push(Message::assistant(thought));
|
||||
// The final answer should be a single assistant message.
|
||||
messages.push(Message::assistant(answer.clone()));
|
||||
return Ok(answer);
|
||||
messages.push(Message::assistant(format!(
|
||||
"THOUGHT: {}\nFINAL_ANSWER: {}",
|
||||
thought, answer
|
||||
)));
|
||||
return Ok(AgentResult {
|
||||
answer,
|
||||
iterations: iteration + 1,
|
||||
messages,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
LlmResponse::Reasoning { thought } => {
|
||||
messages.push(Message::assistant(format!("THOUGHT: {}", thought)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(AgentError::MaxIterationsReached(self.config.max_iterations))
|
||||
|
||||
// Max iterations reached
|
||||
Ok(AgentResult {
|
||||
answer: "Maximum iterations reached without finding a final answer".to_string(),
|
||||
iterations: self.config.max_iterations,
|
||||
messages,
|
||||
success: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover available tools from the MCP client
|
||||
async fn discover_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
self.tool_client.list_tools().await
|
||||
}
|
||||
|
||||
/// Build a ReAct-formatted prompt with available tools
|
||||
fn build_react_prompt(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
tools: &[McpToolDescriptor],
|
||||
) -> Vec<Message> {
|
||||
let mut prompt_messages = Vec::new();
|
||||
|
||||
// System prompt with ReAct instructions
|
||||
let system_prompt = self.build_system_prompt(tools);
|
||||
prompt_messages.push(Message::system(system_prompt));
|
||||
|
||||
// Add conversation history
|
||||
prompt_messages.extend_from_slice(messages);
|
||||
|
||||
prompt_messages
|
||||
}
|
||||
|
||||
/// Build the system prompt with ReAct format and tool descriptions
|
||||
fn build_system_prompt(&self, tools: &[McpToolDescriptor]) -> String {
|
||||
let mut prompt = String::from(
|
||||
"You are an AI assistant that uses the ReAct (Reasoning and Acting) pattern to solve tasks.\n\n\
|
||||
You have access to the following tools:\n\n"
|
||||
);
|
||||
|
||||
for tool in tools {
|
||||
prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description));
|
||||
}
|
||||
|
||||
prompt.push_str(
|
||||
"\nUse the following format:\n\n\
|
||||
THOUGHT: Your reasoning about what to do next\n\
|
||||
ACTION: tool_name\n\
|
||||
ACTION_INPUT: {\"param\": \"value\"}\n\n\
|
||||
You will receive:\n\
|
||||
OBSERVATION: The result of the tool execution\n\n\
|
||||
Continue this process until you have enough information, then provide:\n\
|
||||
THOUGHT: Final reasoning\n\
|
||||
FINAL_ANSWER: Your comprehensive answer\n\n\
|
||||
Important:\n\
|
||||
- Always start with THOUGHT to explain your reasoning\n\
|
||||
- ACTION must be one of the available tools\n\
|
||||
- ACTION_INPUT must be valid JSON\n\
|
||||
- Use FINAL_ANSWER only when you have sufficient information\n",
|
||||
);
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Generate an LLM response
|
||||
async fn generate_llm_response(&self, messages: Vec<Message>) -> Result<String> {
|
||||
let request = ChatRequest {
|
||||
model: self.config.model.clone(),
|
||||
messages,
|
||||
parameters: ChatParameters {
|
||||
temperature: self.config.temperature,
|
||||
max_tokens: self.config.max_tokens,
|
||||
stream: false,
|
||||
..Default::default()
|
||||
},
|
||||
tools: None,
|
||||
};
|
||||
|
||||
let response = self.llm_client.chat(request).await?;
|
||||
Ok(response.message.content)
|
||||
}
|
||||
|
||||
/// Parse LLM response into structured format
|
||||
fn parse_response(&self, text: &str) -> Result<LlmResponse> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut thought = String::new();
|
||||
let mut action = String::new();
|
||||
let mut action_input = String::new();
|
||||
let mut final_answer = String::new();
|
||||
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
|
||||
if line.starts_with("THOUGHT:") {
|
||||
thought = line
|
||||
.strip_prefix("THOUGHT:")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
// Collect multi-line thoughts
|
||||
i += 1;
|
||||
while i < lines.len()
|
||||
&& !lines[i].trim().starts_with("ACTION")
|
||||
&& !lines[i].trim().starts_with("FINAL_ANSWER")
|
||||
{
|
||||
if !lines[i].trim().is_empty() {
|
||||
thought.push(' ');
|
||||
thought.push_str(lines[i].trim());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with("ACTION:") {
|
||||
action = line
|
||||
.strip_prefix("ACTION:")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with("ACTION_INPUT:") {
|
||||
action_input = line
|
||||
.strip_prefix("ACTION_INPUT:")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
// Collect multi-line JSON
|
||||
i += 1;
|
||||
while i < lines.len()
|
||||
&& !lines[i].trim().starts_with("THOUGHT")
|
||||
&& !lines[i].trim().starts_with("ACTION")
|
||||
{
|
||||
action_input.push(' ');
|
||||
action_input.push_str(lines[i].trim());
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.starts_with("FINAL_ANSWER:") {
|
||||
final_answer = line
|
||||
.strip_prefix("FINAL_ANSWER:")
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
// Collect multi-line answer
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
if !lines[i].trim().is_empty() {
|
||||
final_answer.push(' ');
|
||||
final_answer.push_str(lines[i].trim());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Determine response type
|
||||
if !final_answer.is_empty() {
|
||||
return Ok(LlmResponse::FinalAnswer {
|
||||
thought,
|
||||
answer: final_answer,
|
||||
});
|
||||
}
|
||||
|
||||
if !action.is_empty() {
|
||||
let arguments = if action_input.is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::from_str(&action_input)
|
||||
.map_err(|e| Error::Agent(ParseError::InvalidJson(e.to_string()).to_string()))?
|
||||
};
|
||||
|
||||
return Ok(LlmResponse::ToolCall {
|
||||
thought,
|
||||
tool_name: action,
|
||||
arguments,
|
||||
});
|
||||
}
|
||||
|
||||
if !thought.is_empty() {
|
||||
return Ok(LlmResponse::Reasoning { thought });
|
||||
}
|
||||
|
||||
Err(Error::Agent(ParseError::NoPattern.to_string()))
|
||||
}
|
||||
|
||||
/// Execute a tool call
|
||||
async fn execute_tool(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<McpToolResponse> {
|
||||
let call = McpToolCall {
|
||||
name: tool_name.to_string(),
|
||||
arguments,
|
||||
};
|
||||
self.tool_client.call_tool(call).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mcp::test_utils::MockMcpClient;
|
||||
use crate::provider::test_utils::MockProvider;
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_call() {
|
||||
let executor = AgentExecutor {
|
||||
llm_client: Arc::new(MockProvider::new()),
|
||||
tool_client: Arc::new(MockMcpClient::new()),
|
||||
config: AgentConfig::default(),
|
||||
};
|
||||
|
||||
let text = r#"
|
||||
THOUGHT: I need to search for information about Rust
|
||||
ACTION: web_search
|
||||
ACTION_INPUT: {"query": "Rust programming language"}
|
||||
"#;
|
||||
|
||||
let result = executor.parse_response(text).unwrap();
|
||||
match result {
|
||||
LlmResponse::ToolCall {
|
||||
thought,
|
||||
tool_name,
|
||||
arguments,
|
||||
} => {
|
||||
assert!(thought.contains("search for information"));
|
||||
assert_eq!(tool_name, "web_search");
|
||||
assert_eq!(arguments["query"], "Rust programming language");
|
||||
}
|
||||
_ => panic!("Expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_final_answer() {
|
||||
let executor = AgentExecutor {
|
||||
llm_client: Arc::new(MockProvider::new()),
|
||||
tool_client: Arc::new(MockMcpClient::new()),
|
||||
config: AgentConfig::default(),
|
||||
};
|
||||
|
||||
let text = r#"
|
||||
THOUGHT: I now have enough information to answer
|
||||
FINAL_ANSWER: Rust is a systems programming language focused on safety and performance.
|
||||
"#;
|
||||
|
||||
let result = executor.parse_response(text).unwrap();
|
||||
match result {
|
||||
LlmResponse::FinalAnswer { thought, answer } => {
|
||||
assert!(thought.contains("enough information"));
|
||||
assert!(answer.contains("Rust is a systems programming language"));
|
||||
}
|
||||
_ => panic!("Expected FinalAnswer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct Config {
|
||||
/// Mode-specific tool availability configuration
|
||||
#[serde(default)]
|
||||
pub modes: ModeConfig,
|
||||
/// External MCP server definitions
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<McpServerConfig>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -64,10 +67,35 @@ impl Default for Config {
|
||||
security: SecuritySettings::default(),
|
||||
tools: ToolSettings::default(),
|
||||
modes: ModeConfig::default(),
|
||||
mcp_servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for an external MCP server process.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct McpServerConfig {
|
||||
/// Logical name used to reference the server (e.g., "web_search").
|
||||
pub name: String,
|
||||
/// Command to execute (binary or script).
|
||||
pub command: String,
|
||||
/// Arguments passed to the command.
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
/// Transport mechanism, currently only "stdio" is supported.
|
||||
#[serde(default = "McpServerConfig::default_transport")]
|
||||
pub transport: String,
|
||||
/// Optional environment variable map for the process.
|
||||
#[serde(default)]
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl McpServerConfig {
|
||||
fn default_transport() -> String {
|
||||
"stdio".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from disk, falling back to defaults when missing
|
||||
pub fn load(path: Option<&Path>) -> Result<Self> {
|
||||
@@ -214,16 +242,8 @@ 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,
|
||||
// MCP is now always enabled in v1.0+
|
||||
// Kept as a struct for future configuration options
|
||||
}
|
||||
|
||||
/// Privacy controls governing network access and storage
|
||||
@@ -296,6 +316,7 @@ impl SecuritySettings {
|
||||
fn default_allowed_tools() -> Vec<String> {
|
||||
vec![
|
||||
"web_search".to_string(),
|
||||
"web_scrape".to_string(),
|
||||
"code_exec".to_string(),
|
||||
"file_write".to_string(),
|
||||
"file_delete".to_string(),
|
||||
|
||||
@@ -86,4 +86,7 @@ pub enum Error {
|
||||
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("Agent execution error: {0}")]
|
||||
Agent(String),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::time::Duration;
|
||||
|
||||
pub mod client;
|
||||
pub mod factory;
|
||||
pub mod failover;
|
||||
pub mod permission;
|
||||
pub mod protocol;
|
||||
pub mod remote_client;
|
||||
@@ -142,3 +143,45 @@ impl McpClient for LocalMcpClient {
|
||||
self.server.call_tool(call).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test_utils {
|
||||
use super::*;
|
||||
|
||||
/// Mock MCP client for testing
|
||||
pub struct MockMcpClient;
|
||||
|
||||
impl MockMcpClient {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for MockMcpClient {
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
Ok(vec![McpToolDescriptor {
|
||||
name: "mock_tool".to_string(),
|
||||
description: "A mock tool for testing".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}),
|
||||
requires_network: false,
|
||||
requires_filesystem: vec![],
|
||||
}])
|
||||
}
|
||||
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: serde_json::json!({"result": "mock result"}),
|
||||
metadata: HashMap::new(),
|
||||
duration_ms: 10,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::{Error, Result};
|
||||
use crate::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Trait for a client that can interact with an MCP server
|
||||
@@ -12,40 +12,5 @@ pub trait McpClient: Send + Sync {
|
||||
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> {
|
||||
// Attempt to spawn the MCP server binary located at ./target/debug/owlen-mcp-server
|
||||
// The server runs over STDIO and will be managed by the client instance.
|
||||
// For now we just verify that the binary exists; the actual process handling
|
||||
// is performed lazily in the async methods.
|
||||
let path = "./target/debug/owlen-mcp-server";
|
||||
if std::path::Path::new(path).exists() {
|
||||
Ok(Self)
|
||||
} else {
|
||||
Err(Error::NotImplemented(format!(
|
||||
"Remote MCP server binary not found at {}",
|
||||
path
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
// Re-export the concrete implementation that supports stdio and HTTP transports.
|
||||
pub use super::remote_client::RemoteMcpClient;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// Supports switching between local (in-process) and remote (STDIO) execution modes.
|
||||
use super::client::McpClient;
|
||||
use super::{remote_client::RemoteMcpClient, LocalMcpClient};
|
||||
use crate::config::{Config, McpMode};
|
||||
use crate::config::Config;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::validation::SchemaValidator;
|
||||
use crate::Result;
|
||||
@@ -31,28 +31,29 @@ impl McpClientFactory {
|
||||
}
|
||||
|
||||
/// Create an MCP client based on the current configuration
|
||||
///
|
||||
/// In v1.0+, MCP architecture is always enabled. If MCP servers are configured,
|
||||
/// uses the first server; otherwise falls back to local in-process client.
|
||||
pub fn create(&self) -> Result<Box<dyn McpClient>> {
|
||||
match self.config.mcp.mode {
|
||||
McpMode::Legacy => {
|
||||
// Use local in-process client
|
||||
Ok(Box::new(LocalMcpClient::new(
|
||||
self.registry.clone(),
|
||||
self.validator.clone(),
|
||||
)))
|
||||
}
|
||||
McpMode::Enabled => {
|
||||
// Attempt to use remote client, fall back to local if unavailable
|
||||
match RemoteMcpClient::new() {
|
||||
// Use the first configured MCP server, if any.
|
||||
if let Some(server_cfg) = self.config.mcp_servers.first() {
|
||||
match RemoteMcpClient::new_with_config(server_cfg) {
|
||||
Ok(client) => Ok(Box::new(client)),
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to start remote MCP client: {}. Falling back to local mode.", e);
|
||||
eprintln!("Warning: Failed to start remote MCP client '{}': {}. Falling back to local mode.", server_cfg.name, e);
|
||||
Ok(Box::new(LocalMcpClient::new(
|
||||
self.registry.clone(),
|
||||
self.validator.clone(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No servers configured – fall back to local client.
|
||||
eprintln!("Warning: No MCP servers defined in config. Using local client.");
|
||||
Ok(Box::new(LocalMcpClient::new(
|
||||
self.registry.clone(),
|
||||
self.validator.clone(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +68,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_factory_creates_local_client_in_legacy_mode() {
|
||||
let mut config = Config::default();
|
||||
config.mcp.mode = McpMode::Legacy;
|
||||
fn test_factory_creates_local_client_when_no_servers_configured() {
|
||||
let config = Config::default();
|
||||
|
||||
let ui = Arc::new(crate::ui::NoOpUiController);
|
||||
let registry = Arc::new(ToolRegistry::new(
|
||||
@@ -80,7 +80,7 @@ mod tests {
|
||||
|
||||
let factory = McpClientFactory::new(Arc::new(config), registry, validator);
|
||||
|
||||
// Should create without error
|
||||
// Should create without error and fall back to local client
|
||||
let result = factory.create();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
322
crates/owlen-core/src/mcp/failover.rs
Normal file
322
crates/owlen-core/src/mcp/failover.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! Failover and redundancy support for MCP clients
|
||||
//!
|
||||
//! Provides automatic failover between multiple MCP servers with:
|
||||
//! - Health checking
|
||||
//! - Priority-based selection
|
||||
//! - Automatic retry with exponential backoff
|
||||
//! - Circuit breaker pattern
|
||||
|
||||
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Server health status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ServerHealth {
|
||||
/// Server is healthy and available
|
||||
Healthy,
|
||||
/// Server is experiencing issues but may recover
|
||||
Degraded { since: Instant },
|
||||
/// Server is down
|
||||
Down { since: Instant },
|
||||
}
|
||||
|
||||
/// Server configuration with priority
|
||||
#[derive(Clone)]
|
||||
pub struct ServerEntry {
|
||||
/// Name for logging
|
||||
pub name: String,
|
||||
/// MCP client instance
|
||||
pub client: Arc<dyn McpClient>,
|
||||
/// Priority (lower = higher priority)
|
||||
pub priority: u32,
|
||||
/// Health status
|
||||
health: Arc<RwLock<ServerHealth>>,
|
||||
/// Last health check time
|
||||
last_check: Arc<RwLock<Option<Instant>>>,
|
||||
}
|
||||
|
||||
impl ServerEntry {
|
||||
pub fn new(name: String, client: Arc<dyn McpClient>, priority: u32) -> Self {
|
||||
Self {
|
||||
name,
|
||||
client,
|
||||
priority,
|
||||
health: Arc::new(RwLock::new(ServerHealth::Healthy)),
|
||||
last_check: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if server is available
|
||||
pub async fn is_available(&self) -> bool {
|
||||
let health = self.health.read().await;
|
||||
matches!(*health, ServerHealth::Healthy)
|
||||
}
|
||||
|
||||
/// Mark server as healthy
|
||||
pub async fn mark_healthy(&self) {
|
||||
let mut health = self.health.write().await;
|
||||
*health = ServerHealth::Healthy;
|
||||
let mut last_check = self.last_check.write().await;
|
||||
*last_check = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Mark server as down
|
||||
pub async fn mark_down(&self) {
|
||||
let mut health = self.health.write().await;
|
||||
*health = ServerHealth::Down {
|
||||
since: Instant::now(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Mark server as degraded
|
||||
pub async fn mark_degraded(&self) {
|
||||
let mut health = self.health.write().await;
|
||||
if matches!(*health, ServerHealth::Healthy) {
|
||||
*health = ServerHealth::Degraded {
|
||||
since: Instant::now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current health status
|
||||
pub async fn get_health(&self) -> ServerHealth {
|
||||
self.health.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Failover configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FailoverConfig {
|
||||
/// Maximum number of retry attempts
|
||||
pub max_retries: usize,
|
||||
/// Base retry delay (will be exponentially increased)
|
||||
pub base_retry_delay: Duration,
|
||||
/// Health check interval
|
||||
pub health_check_interval: Duration,
|
||||
/// Timeout for health checks
|
||||
pub health_check_timeout: Duration,
|
||||
/// Circuit breaker threshold (failures before opening circuit)
|
||||
pub circuit_breaker_threshold: usize,
|
||||
}
|
||||
|
||||
impl Default for FailoverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 3,
|
||||
base_retry_delay: Duration::from_millis(100),
|
||||
health_check_interval: Duration::from_secs(30),
|
||||
health_check_timeout: Duration::from_secs(5),
|
||||
circuit_breaker_threshold: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP client with failover support
|
||||
pub struct FailoverMcpClient {
|
||||
servers: Arc<RwLock<Vec<ServerEntry>>>,
|
||||
config: FailoverConfig,
|
||||
consecutive_failures: Arc<RwLock<usize>>,
|
||||
}
|
||||
|
||||
impl FailoverMcpClient {
|
||||
/// Create a new failover client with multiple servers
|
||||
pub fn new(servers: Vec<ServerEntry>, config: FailoverConfig) -> Self {
|
||||
// Sort servers by priority
|
||||
let mut sorted_servers = servers;
|
||||
sorted_servers.sort_by_key(|s| s.priority);
|
||||
|
||||
Self {
|
||||
servers: Arc::new(RwLock::new(sorted_servers)),
|
||||
config,
|
||||
consecutive_failures: Arc::new(RwLock::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with default configuration
|
||||
pub fn with_servers(servers: Vec<ServerEntry>) -> Self {
|
||||
Self::new(servers, FailoverConfig::default())
|
||||
}
|
||||
|
||||
/// Get the first available server
|
||||
async fn get_available_server(&self) -> Option<ServerEntry> {
|
||||
let servers = self.servers.read().await;
|
||||
for server in servers.iter() {
|
||||
if server.is_available().await {
|
||||
return Some(server.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Execute an operation with automatic failover
|
||||
async fn with_failover<F, T>(&self, operation: F) -> Result<T>
|
||||
where
|
||||
F: Fn(Arc<dyn McpClient>) -> futures::future::BoxFuture<'static, Result<T>>,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let mut attempt = 0;
|
||||
let mut last_error = None;
|
||||
|
||||
while attempt < self.config.max_retries {
|
||||
// Get available server
|
||||
let server = match self.get_available_server().await {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// No healthy servers, try all servers anyway
|
||||
let servers = self.servers.read().await;
|
||||
if let Some(first) = servers.first() {
|
||||
first.clone()
|
||||
} else {
|
||||
return Err(Error::Network("No servers configured".to_string()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute operation
|
||||
match operation(server.client.clone()).await {
|
||||
Ok(result) => {
|
||||
server.mark_healthy().await;
|
||||
let mut failures = self.consecutive_failures.write().await;
|
||||
*failures = 0;
|
||||
return Ok(result);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Server '{}' failed: {}", server.name, e);
|
||||
server.mark_degraded().await;
|
||||
last_error = Some(e);
|
||||
|
||||
let mut failures = self.consecutive_failures.write().await;
|
||||
*failures += 1;
|
||||
|
||||
if *failures >= self.config.circuit_breaker_threshold {
|
||||
server.mark_down().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
if attempt < self.config.max_retries - 1 {
|
||||
let delay = self.config.base_retry_delay * 2_u32.pow(attempt as u32);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| Error::Network("All servers failed".to_string())))
|
||||
}
|
||||
|
||||
/// Perform health check on all servers
|
||||
pub async fn health_check_all(&self) {
|
||||
let servers = self.servers.read().await;
|
||||
for server in servers.iter() {
|
||||
let client = server.client.clone();
|
||||
let server_clone = server.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
// Use a simple list_tools call as health check
|
||||
async { client.list_tools().await },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => server_clone.mark_healthy().await,
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("Health check failed for '{}': {}", server_clone.name, e);
|
||||
server_clone.mark_down().await;
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!("Health check timeout for '{}'", server_clone.name);
|
||||
server_clone.mark_down().await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Start background health checking
|
||||
pub fn start_health_checks(&self) -> tokio::task::JoinHandle<()> {
|
||||
let client = self.clone_ref();
|
||||
let interval = self.config.health_check_interval;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval_timer = tokio::time::interval(interval);
|
||||
loop {
|
||||
interval_timer.tick().await;
|
||||
client.health_check_all().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Clone the client (returns new handle to same underlying data)
|
||||
fn clone_ref(&self) -> Self {
|
||||
Self {
|
||||
servers: self.servers.clone(),
|
||||
config: self.config.clone(),
|
||||
consecutive_failures: self.consecutive_failures.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status of all servers
|
||||
pub async fn get_server_status(&self) -> Vec<(String, ServerHealth)> {
|
||||
let servers = self.servers.read().await;
|
||||
let mut status = Vec::new();
|
||||
for server in servers.iter() {
|
||||
status.push((server.name.clone(), server.get_health().await));
|
||||
}
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for FailoverMcpClient {
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
self.with_failover(|client| Box::pin(async move { client.list_tools().await }))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
self.with_failover(|client| {
|
||||
let call_clone = call.clone();
|
||||
Box::pin(async move { client.call_tool(call_clone).await })
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_entry_health() {
|
||||
use crate::mcp::remote_client::RemoteMcpClient;
|
||||
|
||||
// This would need a mock client in practice
|
||||
// Just demonstrating the API
|
||||
let config = crate::config::McpServerConfig {
|
||||
name: "test".to_string(),
|
||||
command: "test".to_string(),
|
||||
args: vec![],
|
||||
transport: "http".to_string(),
|
||||
env: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
if let Ok(client) = RemoteMcpClient::new_with_config(&config) {
|
||||
let entry = ServerEntry::new("test".to_string(), Arc::new(client), 1);
|
||||
|
||||
assert!(entry.is_available().await);
|
||||
|
||||
entry.mark_down().await;
|
||||
assert!(!entry.is_available().await);
|
||||
|
||||
entry.mark_healthy().await;
|
||||
assert!(entry.is_available().await);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,78 @@
|
||||
use super::protocol::methods;
|
||||
use super::protocol::{RequestId, RpcErrorResponse, RpcRequest, RpcResponse, PROTOCOL_VERSION};
|
||||
use super::protocol::{
|
||||
RequestId, RpcErrorResponse, RpcNotification, RpcRequest, RpcResponse, PROTOCOL_VERSION,
|
||||
};
|
||||
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::consent::{ConsentManager, ConsentScope};
|
||||
use crate::tools::{Tool, WebScrapeTool, WebSearchTool};
|
||||
use crate::types::ModelInfo;
|
||||
use crate::{Error, Provider, Result};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client as HttpClient;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use tungstenite::protocol::Message as WsMessage;
|
||||
// Provider trait is already imported via the earlier use statement.
|
||||
use crate::types::{ChatResponse, Message, Role};
|
||||
use futures::stream;
|
||||
use futures::StreamExt;
|
||||
|
||||
/// Client that talks to the external `owlen-mcp-server` over STDIO.
|
||||
/// Client that talks to the external `owlen-mcp-server` over STDIO, HTTP, or WebSocket.
|
||||
pub struct RemoteMcpClient {
|
||||
// Child process handling the server (kept alive for the duration of the client).
|
||||
#[allow(dead_code)]
|
||||
child: Arc<Mutex<Child>>, // guarded for mutable access across calls
|
||||
// Writer to server stdin.
|
||||
stdin: Arc<Mutex<tokio::process::ChildStdin>>, // async write
|
||||
// Reader for server stdout.
|
||||
stdout: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
|
||||
// For stdio transport, we keep the child process handles.
|
||||
child: Option<Arc<Mutex<Child>>>,
|
||||
stdin: Option<Arc<Mutex<tokio::process::ChildStdin>>>, // async write
|
||||
stdout: Option<Arc<Mutex<BufReader<tokio::process::ChildStdout>>>>,
|
||||
// For HTTP transport we keep a reusable client and base URL.
|
||||
http_client: Option<HttpClient>,
|
||||
http_endpoint: Option<String>,
|
||||
// For WebSocket transport we keep a WebSocket stream.
|
||||
ws_stream: Option<Arc<Mutex<WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>>>>,
|
||||
#[allow(dead_code)] // Useful for debugging/logging
|
||||
ws_endpoint: Option<String>,
|
||||
// Incrementing request identifier.
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl RemoteMcpClient {
|
||||
/// Spawn the MCP server binary and prepare communication channels.
|
||||
pub fn new() -> Result<Self> {
|
||||
// Locate the binary – it is built by Cargo into target/debug.
|
||||
// The test binary runs inside the crate directory, so we check a couple of relative locations.
|
||||
// Attempt to locate the server binary; if unavailable we will fall back to launching via `cargo run`.
|
||||
let _ = ();
|
||||
// Resolve absolute path based on workspace root to avoid cwd dependence.
|
||||
// The MCP server binary lives in the workspace's `target/debug` directory.
|
||||
// Historically the binary was named `owlen-mcp-server`, but it has been
|
||||
// renamed to `owlen-mcp-llm-server`. We attempt to locate the new name
|
||||
// first and fall back to the legacy name for compatibility.
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.map_err(Error::Io)?;
|
||||
let candidates = [
|
||||
"target/debug/owlen-mcp-llm-server",
|
||||
"target/debug/owlen-mcp-server",
|
||||
];
|
||||
let mut binary_path = None;
|
||||
for rel in &candidates {
|
||||
let p = workspace_root.join(rel);
|
||||
if p.exists() {
|
||||
binary_path = Some(p);
|
||||
break;
|
||||
/// Spawn an MCP server based on a configuration entry.
|
||||
/// The `transport` field must be "stdio" (the only supported mode).
|
||||
/// 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> {
|
||||
let transport = config.transport.to_lowercase();
|
||||
match transport.as_str() {
|
||||
"stdio" => {
|
||||
// Build the command using the provided binary and arguments.
|
||||
let mut cmd = Command::new(config.command.clone());
|
||||
if !config.args.is_empty() {
|
||||
cmd.args(config.args.clone());
|
||||
}
|
||||
cmd.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit());
|
||||
|
||||
// Apply environment variables defined in the configuration.
|
||||
for (k, v) in config.env.iter() {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let binary_path = binary_path.ok_or_else(|| {
|
||||
Error::NotImplemented(format!(
|
||||
"owlen-mcp server binary not found; checked {} and {}",
|
||||
candidates[0], candidates[1]
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
Error::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to spawn MCP server '{}': {}", config.name, e),
|
||||
))
|
||||
})?;
|
||||
if !binary_path.exists() {
|
||||
return Err(Error::NotImplemented(format!(
|
||||
"owlen-mcp-server binary not found at {}",
|
||||
binary_path.display()
|
||||
)));
|
||||
}
|
||||
// Launch the already‑built server binary directly.
|
||||
let mut child = Command::new(&binary_path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.spawn()
|
||||
.map_err(Error::Io)?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or_else(|| {
|
||||
Error::Io(std::io::Error::other(
|
||||
@@ -88,41 +86,239 @@ impl RemoteMcpClient {
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
stdin: Arc::new(Mutex::new(stdin)),
|
||||
stdout: Arc::new(Mutex::new(BufReader::new(stdout))),
|
||||
child: Some(Arc::new(Mutex::new(child))),
|
||||
stdin: Some(Arc::new(Mutex::new(stdin))),
|
||||
stdout: Some(Arc::new(Mutex::new(BufReader::new(stdout)))),
|
||||
http_client: None,
|
||||
http_endpoint: None,
|
||||
ws_stream: None,
|
||||
ws_endpoint: None,
|
||||
next_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
"http" => {
|
||||
// For HTTP we treat `command` as the base URL.
|
||||
let client = HttpClient::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| Error::Network(e.to_string()))?;
|
||||
Ok(Self {
|
||||
child: None,
|
||||
stdin: None,
|
||||
stdout: None,
|
||||
http_client: Some(client),
|
||||
http_endpoint: Some(config.command.clone()),
|
||||
ws_stream: None,
|
||||
ws_endpoint: None,
|
||||
next_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
"websocket" => {
|
||||
// For WebSocket, the `command` field contains the WebSocket URL.
|
||||
// We need to use a blocking task to establish the connection.
|
||||
let ws_url = config.command.clone();
|
||||
let (ws_stream, _response) = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(async {
|
||||
connect_async(&ws_url).await.map_err(|e| {
|
||||
Error::Network(format!("WebSocket connection failed: {}", e))
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
child: None,
|
||||
stdin: None,
|
||||
stdout: None,
|
||||
http_client: None,
|
||||
http_endpoint: None,
|
||||
ws_stream: Some(Arc::new(Mutex::new(ws_stream))),
|
||||
ws_endpoint: Some(ws_url),
|
||||
next_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
other => Err(Error::NotImplemented(format!(
|
||||
"Transport '{}' not supported",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy constructor kept for compatibility; attempts to locate a binary.
|
||||
pub fn new() -> Result<Self> {
|
||||
// Fall back to searching for a binary as before, then delegate to new_with_config.
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.map_err(Error::Io)?;
|
||||
// Prefer the LLM server binary as it provides both LLM and resource tools.
|
||||
// The generic file-server is kept as a fallback for testing.
|
||||
let candidates = [
|
||||
"target/debug/owlen-mcp-llm-server",
|
||||
"target/release/owlen-mcp-llm-server",
|
||||
"target/debug/owlen-mcp-server",
|
||||
];
|
||||
let binary_path = candidates
|
||||
.iter()
|
||||
.map(|rel| workspace_root.join(rel))
|
||||
.find(|p| p.exists())
|
||||
.ok_or_else(|| {
|
||||
Error::NotImplemented(format!(
|
||||
"owlen-mcp server binary not found; checked {}, {}, and {}",
|
||||
candidates[0], candidates[1], candidates[2]
|
||||
))
|
||||
})?;
|
||||
let config = crate::config::McpServerConfig {
|
||||
name: "default".to_string(),
|
||||
command: binary_path.to_string_lossy().into_owned(),
|
||||
args: Vec::new(),
|
||||
transport: "stdio".to_string(),
|
||||
env: std::collections::HashMap::new(),
|
||||
};
|
||||
Self::new_with_config(&config)
|
||||
}
|
||||
|
||||
async fn send_rpc(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let id = RequestId::Number(self.next_id.fetch_add(1, Ordering::Relaxed));
|
||||
let request = RpcRequest::new(id.clone(), method, Some(params));
|
||||
let req_str = serde_json::to_string(&request)? + "\n";
|
||||
{
|
||||
let mut stdin = self.stdin.lock().await;
|
||||
// For stdio transport we forward the request to the child process.
|
||||
if let Some(stdin_arc) = &self.stdin {
|
||||
let mut stdin = stdin_arc.lock().await;
|
||||
stdin.write_all(req_str.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
}
|
||||
// Read a single line response
|
||||
// Handle based on selected transport.
|
||||
if let Some(client) = &self.http_client {
|
||||
// HTTP: POST JSON body to endpoint.
|
||||
let endpoint = self
|
||||
.http_endpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::Network("Missing HTTP endpoint".into()))?;
|
||||
let resp = client
|
||||
.post(endpoint)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::Network(e.to_string()))?;
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| Error::Network(e.to_string()))?;
|
||||
// Try to parse as success then error.
|
||||
if let Ok(r) = serde_json::from_str::<RpcResponse>(&text) {
|
||||
if r.id == id {
|
||||
return Ok(r.result);
|
||||
}
|
||||
}
|
||||
let err_resp: RpcErrorResponse =
|
||||
serde_json::from_str(&text).map_err(Error::Serialization)?;
|
||||
return Err(Error::Network(format!(
|
||||
"MCP server error {}: {}",
|
||||
err_resp.error.code, err_resp.error.message
|
||||
)));
|
||||
}
|
||||
|
||||
// WebSocket path.
|
||||
if let Some(ws_arc) = &self.ws_stream {
|
||||
use futures::SinkExt;
|
||||
|
||||
let mut ws = ws_arc.lock().await;
|
||||
|
||||
// Send request as text message
|
||||
let req_json = serde_json::to_string(&request)?;
|
||||
ws.send(WsMessage::Text(req_json))
|
||||
.await
|
||||
.map_err(|e| Error::Network(format!("WebSocket send failed: {}", e)))?;
|
||||
|
||||
// Read response
|
||||
let response_msg = ws
|
||||
.next()
|
||||
.await
|
||||
.ok_or_else(|| Error::Network("WebSocket stream closed".into()))?
|
||||
.map_err(|e| Error::Network(format!("WebSocket receive failed: {}", e)))?;
|
||||
|
||||
let response_text = match response_msg {
|
||||
WsMessage::Text(text) => text,
|
||||
WsMessage::Binary(data) => String::from_utf8(data).map_err(|e| {
|
||||
Error::Network(format!("Invalid UTF-8 in binary message: {}", e))
|
||||
})?,
|
||||
WsMessage::Close(_) => {
|
||||
return Err(Error::Network(
|
||||
"WebSocket connection closed by server".into(),
|
||||
));
|
||||
}
|
||||
_ => return Err(Error::Network("Unexpected WebSocket message type".into())),
|
||||
};
|
||||
|
||||
// Try to parse as success then error.
|
||||
if let Ok(r) = serde_json::from_str::<RpcResponse>(&response_text) {
|
||||
if r.id == id {
|
||||
return Ok(r.result);
|
||||
}
|
||||
}
|
||||
let err_resp: RpcErrorResponse =
|
||||
serde_json::from_str(&response_text).map_err(Error::Serialization)?;
|
||||
return Err(Error::Network(format!(
|
||||
"MCP server error {}: {}",
|
||||
err_resp.error.code, err_resp.error.message
|
||||
)));
|
||||
}
|
||||
|
||||
// STDIO path (default).
|
||||
// Loop to skip notifications and find the response with matching ID.
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
{
|
||||
let mut stdout = self.stdout.lock().await;
|
||||
let mut stdout = self
|
||||
.stdout
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::Network("STDIO stdout not available".into()))?
|
||||
.lock()
|
||||
.await;
|
||||
stdout.read_line(&mut line).await?;
|
||||
}
|
||||
// Try to parse successful response first
|
||||
|
||||
// Try to parse as notification first (has no id field)
|
||||
if let Ok(_notif) = serde_json::from_str::<RpcNotification>(&line) {
|
||||
// Skip notifications and continue reading
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse successful response
|
||||
if let Ok(resp) = serde_json::from_str::<RpcResponse>(&line) {
|
||||
if resp.id == id {
|
||||
return Ok(resp.result);
|
||||
}
|
||||
// If ID doesn't match, continue (though this shouldn't happen)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback to error response
|
||||
let err_resp: RpcErrorResponse =
|
||||
serde_json::from_str(&line).map_err(Error::Serialization)?;
|
||||
Err(Error::Network(format!(
|
||||
if let Ok(err_resp) = serde_json::from_str::<RpcErrorResponse>(&line) {
|
||||
return Err(Error::Network(format!(
|
||||
"MCP server error {}: {}",
|
||||
err_resp.error.code, err_resp.error.message
|
||||
)))
|
||||
)));
|
||||
}
|
||||
|
||||
// If we can't parse as any known type, return error
|
||||
return Err(Error::Network(format!(
|
||||
"Unable to parse server response: {}",
|
||||
line.trim()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteMcpClient {
|
||||
/// Convenience wrapper delegating to the `McpClient` trait methods.
|
||||
pub async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
<Self as McpClient>::list_tools(self).await
|
||||
}
|
||||
|
||||
pub async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
<Self as McpClient>::call_tool(self, call).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,18 +371,96 @@ impl McpClient for RemoteMcpClient {
|
||||
duration_ms: 0,
|
||||
});
|
||||
}
|
||||
// Handle write and delete resources locally as well.
|
||||
if call.name.starts_with("resources/write") {
|
||||
let path = call
|
||||
.arguments
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
|
||||
// Simple path‑traversal protection: reject any path containing ".." or absolute paths.
|
||||
if path.contains("..") || Path::new(path).is_absolute() {
|
||||
return Err(Error::InvalidInput("path traversal".into()));
|
||||
}
|
||||
let content = call
|
||||
.arguments
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::InvalidInput("content missing".into()))?;
|
||||
std::fs::write(path, content).map_err(Error::Io)?;
|
||||
return Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: serde_json::json!(null),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
duration_ms: 0,
|
||||
});
|
||||
}
|
||||
if call.name.starts_with("resources/delete") {
|
||||
let path = call
|
||||
.arguments
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::InvalidInput("path missing".into()))?;
|
||||
if path.contains("..") || Path::new(path).is_absolute() {
|
||||
return Err(Error::InvalidInput("path traversal".into()));
|
||||
}
|
||||
std::fs::remove_file(path).map_err(Error::Io)?;
|
||||
return Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: serde_json::json!(null),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
duration_ms: 0,
|
||||
});
|
||||
}
|
||||
// Local handling for web tools to avoid needing an external MCP server.
|
||||
if call.name == "web_search" {
|
||||
// Auto‑grant consent for the web_search tool (permanent for this process).
|
||||
let consent_manager = std::sync::Arc::new(std::sync::Mutex::new(ConsentManager::new()));
|
||||
{
|
||||
let mut cm = consent_manager.lock().unwrap();
|
||||
cm.grant_consent_with_scope(
|
||||
"web_search",
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
ConsentScope::Permanent,
|
||||
);
|
||||
}
|
||||
let tool = WebSearchTool::new(consent_manager.clone(), None, None);
|
||||
let result = tool
|
||||
.execute(call.arguments.clone())
|
||||
.await
|
||||
.map_err(|e| Error::Provider(e.into()))?;
|
||||
return Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: result.output,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
duration_ms: result.duration.as_millis() as u128,
|
||||
});
|
||||
}
|
||||
if call.name == "web_scrape" {
|
||||
let tool = WebScrapeTool::new();
|
||||
let result = tool
|
||||
.execute(call.arguments.clone())
|
||||
.await
|
||||
.map_err(|e| Error::Provider(e.into()))?;
|
||||
return Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: result.output,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
duration_ms: result.duration.as_millis() as u128,
|
||||
});
|
||||
}
|
||||
// MCP server expects a generic "tools/call" method with a payload containing the
|
||||
// specific tool name and its arguments. Wrap the incoming call accordingly.
|
||||
let payload = serde_json::to_value(&call)?;
|
||||
let result = self.send_rpc(methods::TOOLS_CALL, payload).await?;
|
||||
// The server returns the tool's output directly; construct a matching response.
|
||||
Ok(McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: result,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
duration_ms: 0,
|
||||
})
|
||||
// The server returns an McpToolResponse; deserialize it.
|
||||
let response: McpToolResponse = serde_json::from_value(result)?;
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,3 +174,54 @@ impl Default for ProviderRegistry {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod test_utils {
|
||||
use super::*;
|
||||
use crate::types::{ChatRequest, ChatResponse, Message, ModelInfo, Role};
|
||||
|
||||
/// Mock provider for testing
|
||||
pub struct MockProvider;
|
||||
|
||||
impl MockProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for MockProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
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(),
|
||||
description: None,
|
||||
context_window: None,
|
||||
capabilities: vec![],
|
||||
supports_tools: false,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse> {
|
||||
Ok(ChatResponse {
|
||||
message: Message::new(Role::Assistant, "Mock response".to_string()),
|
||||
usage: None,
|
||||
is_streaming: false,
|
||||
is_final: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn chat_stream(&self, _request: ChatRequest) -> Result<ChatStream> {
|
||||
unimplemented!("MockProvider does not support streaming")
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::ui::UiController;
|
||||
use crate::validation::{get_builtin_schemas, SchemaValidator};
|
||||
use crate::{
|
||||
CodeExecTool, ResourcesDeleteTool, ResourcesGetTool, ResourcesListTool, ResourcesWriteTool,
|
||||
ToolRegistry, WebSearchDetailedTool, WebSearchTool,
|
||||
ToolRegistry, WebScrapeTool, WebSearchDetailedTool, WebSearchTool,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
use log::warn;
|
||||
@@ -91,6 +91,19 @@ async fn build_tools(
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
// Register web_scrape tool if allowed.
|
||||
if config_guard
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "web_scrape")
|
||||
&& config_guard.tools.web_search.enabled // reuse web_search toggle for simplicity
|
||||
&& config_guard.privacy.enable_remote_search
|
||||
{
|
||||
let tool = WebScrapeTool::new();
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
if config_guard
|
||||
.security
|
||||
.allowed_tools
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod code_exec;
|
||||
pub mod fs_tools;
|
||||
pub mod registry;
|
||||
pub mod web_scrape;
|
||||
pub mod web_search;
|
||||
pub mod web_search_detailed;
|
||||
|
||||
@@ -91,5 +92,6 @@ impl ToolResult {
|
||||
pub use code_exec::CodeExecTool;
|
||||
pub use fs_tools::{ResourcesDeleteTool, ResourcesGetTool, ResourcesListTool, ResourcesWriteTool};
|
||||
pub use registry::ToolRegistry;
|
||||
pub use web_scrape::WebScrapeTool;
|
||||
pub use web_search::WebSearchTool;
|
||||
pub use web_search_detailed::WebSearchDetailedTool;
|
||||
|
||||
102
crates/owlen-core/src/tools/web_scrape.rs
Normal file
102
crates/owlen-core/src/tools/web_scrape.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use super::{Tool, ToolResult};
|
||||
use crate::Result;
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Tool that fetches the raw HTML content for a list of URLs.
|
||||
///
|
||||
/// Input schema expects:
|
||||
/// urls: array of strings (max 5 URLs)
|
||||
/// timeout_secs: optional integer per‑request timeout (default 10)
|
||||
pub struct WebScrapeTool {
|
||||
// No special dependencies; uses reqwest_011 for compatibility with existing web_search.
|
||||
client: reqwest_011::Client,
|
||||
}
|
||||
|
||||
impl Default for WebScrapeTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebScrapeTool {
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest_011::Client::builder()
|
||||
.user_agent("OwlenWebScrape/0.1")
|
||||
.build()
|
||||
.expect("Failed to build reqwest client");
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WebScrapeTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"web_scrape"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Fetch raw HTML content for a list of URLs"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "format": "uri" },
|
||||
"minItems": 1,
|
||||
"maxItems": 5,
|
||||
"description": "List of URLs to scrape"
|
||||
},
|
||||
"timeout_secs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 30,
|
||||
"default": 10,
|
||||
"description": "Per‑request timeout in seconds"
|
||||
}
|
||||
},
|
||||
"required": ["urls"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn requires_network(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let urls = args
|
||||
.get("urls")
|
||||
.and_then(|v| v.as_array())
|
||||
.context("Missing 'urls' array")?;
|
||||
let timeout_secs = args
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
|
||||
let mut results = Vec::new();
|
||||
for url_val in urls {
|
||||
let url = url_val.as_str().unwrap_or("");
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.send()
|
||||
.await;
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
results.push(json!({ "url": url, "content": text }));
|
||||
}
|
||||
Err(e) => {
|
||||
results.push(json!({ "url": url, "error": e.to_string() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ToolResult::success(json!({ "pages": results })))
|
||||
}
|
||||
}
|
||||
107
crates/owlen-core/tests/mode_tool_filter.rs
Normal file
107
crates/owlen-core/tests/mode_tool_filter.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Tests for mode‑based tool availability filtering.
|
||||
//!
|
||||
//! These tests verify that `ToolRegistry::execute` respects the
|
||||
//! `ModeConfig` settings in `Config`. The default configuration only
|
||||
//! allows `web_search` in chat mode and all tools in code mode.
|
||||
//!
|
||||
//! We create a simple mock tool (`EchoTool`) that just echoes the
|
||||
//! provided arguments. By customizing the `Config` we can test both the
|
||||
//! allowed‑in‑chat and disallowed‑in‑any‑mode paths.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use owlen_core::config::Config;
|
||||
use owlen_core::mode::{Mode, ModeConfig, ModeToolConfig};
|
||||
use owlen_core::tools::registry::ToolRegistry;
|
||||
use owlen_core::tools::{Tool, ToolResult};
|
||||
use owlen_core::ui::{NoOpUiController, UiController};
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// A trivial tool that returns the provided arguments as its output.
|
||||
#[derive(Debug)]
|
||||
struct EchoTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for EchoTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"echo"
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"Echo the input arguments"
|
||||
}
|
||||
fn schema(&self) -> serde_json::Value {
|
||||
// Accept any object.
|
||||
json!({ "type": "object" })
|
||||
}
|
||||
async fn execute(&self, args: serde_json::Value) -> owlen_core::Result<ToolResult> {
|
||||
Ok(ToolResult::success(args))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_allowed_in_chat_mode() {
|
||||
// Build a config where the `echo` tool is explicitly allowed in chat.
|
||||
let mut cfg = Config::default();
|
||||
cfg.modes = ModeConfig {
|
||||
chat: ModeToolConfig {
|
||||
allowed_tools: vec!["echo".to_string()],
|
||||
},
|
||||
code: ModeToolConfig {
|
||||
allowed_tools: vec!["*".to_string()],
|
||||
},
|
||||
};
|
||||
let cfg = Arc::new(Mutex::new(cfg));
|
||||
|
||||
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
||||
let mut reg = ToolRegistry::new(cfg.clone(), ui);
|
||||
reg.register(EchoTool);
|
||||
|
||||
let args = json!({ "msg": "hello" });
|
||||
let result = reg
|
||||
.execute("echo", args.clone(), Mode::Chat)
|
||||
.await
|
||||
.expect("execution should succeed");
|
||||
|
||||
assert!(result.success, "Tool should succeed when allowed");
|
||||
assert_eq!(result.output, args, "Output should echo the input");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_not_allowed_in_any_mode() {
|
||||
// Config that does NOT list `echo` in either mode.
|
||||
let mut cfg = Config::default();
|
||||
cfg.modes = ModeConfig {
|
||||
chat: ModeToolConfig {
|
||||
allowed_tools: vec!["web_search".to_string()],
|
||||
},
|
||||
code: ModeToolConfig {
|
||||
allowed_tools: vec!["*".to_string()], // allow all in code
|
||||
},
|
||||
};
|
||||
// Remove the wildcard for code to simulate strict denial.
|
||||
cfg.modes.code.allowed_tools = vec!["web_search".to_string()];
|
||||
let cfg = Arc::new(Mutex::new(cfg));
|
||||
|
||||
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
||||
let mut reg = ToolRegistry::new(cfg.clone(), ui);
|
||||
reg.register(EchoTool);
|
||||
|
||||
let args = json!({ "msg": "hello" });
|
||||
let result = reg
|
||||
.execute("echo", args, Mode::Chat)
|
||||
.await
|
||||
.expect("execution should return a ToolResult");
|
||||
|
||||
// Expect an error indicating the tool is unavailable in any mode.
|
||||
assert!(!result.success, "Tool should be rejected when not allowed");
|
||||
let err_msg = result
|
||||
.output
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
err_msg.contains("not available in any mode"),
|
||||
"Error message should explain unavailability"
|
||||
);
|
||||
}
|
||||
311
crates/owlen-core/tests/phase9_remoting.rs
Normal file
311
crates/owlen-core/tests/phase9_remoting.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
//! Integration tests for Phase 9: Remoting / Cloud Hybrid Deployment
|
||||
//!
|
||||
//! Tests WebSocket transport, failover mechanisms, and health checking.
|
||||
|
||||
use owlen_core::mcp::failover::{FailoverConfig, FailoverMcpClient, ServerEntry, ServerHealth};
|
||||
use owlen_core::mcp::{McpClient, McpToolCall, McpToolDescriptor};
|
||||
use owlen_core::{Error, Result};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Mock MCP client for testing failover behavior
|
||||
struct MockMcpClient {
|
||||
name: String,
|
||||
fail_count: AtomicUsize,
|
||||
max_failures: usize,
|
||||
}
|
||||
|
||||
impl MockMcpClient {
|
||||
fn new(name: &str, max_failures: usize) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
fail_count: AtomicUsize::new(0),
|
||||
max_failures,
|
||||
}
|
||||
}
|
||||
|
||||
fn always_healthy(name: &str) -> Self {
|
||||
Self::new(name, 0)
|
||||
}
|
||||
|
||||
fn fail_n_times(name: &str, n: usize) -> Self {
|
||||
Self::new(name, n)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpClient for MockMcpClient {
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
let current = self.fail_count.fetch_add(1, Ordering::SeqCst);
|
||||
if current < self.max_failures {
|
||||
Err(Error::Network(format!(
|
||||
"Mock failure {} from '{}'",
|
||||
current + 1,
|
||||
self.name
|
||||
)))
|
||||
} else {
|
||||
Ok(vec![McpToolDescriptor {
|
||||
name: format!("test_tool_{}", self.name),
|
||||
description: format!("Tool from {}", self.name),
|
||||
input_schema: serde_json::json!({}),
|
||||
requires_network: false,
|
||||
requires_filesystem: vec![],
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<owlen_core::mcp::McpToolResponse> {
|
||||
let current = self.fail_count.load(Ordering::SeqCst);
|
||||
if current < self.max_failures {
|
||||
Err(Error::Network(format!("Mock failure from '{}'", self.name)))
|
||||
} else {
|
||||
Ok(owlen_core::mcp::McpToolResponse {
|
||||
name: call.name,
|
||||
success: true,
|
||||
output: serde_json::json!({ "server": self.name }),
|
||||
metadata: std::collections::HashMap::new(),
|
||||
duration_ms: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_failover_basic_priority() {
|
||||
// Create two healthy servers with different priorities
|
||||
let primary = Arc::new(MockMcpClient::always_healthy("primary"));
|
||||
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
|
||||
|
||||
let servers = vec![
|
||||
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
|
||||
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
|
||||
];
|
||||
|
||||
let client = FailoverMcpClient::with_servers(servers);
|
||||
|
||||
// Should use primary (lower priority number)
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0].name, "test_tool_primary");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_failover_with_retry() {
|
||||
// Primary fails 2 times, then succeeds
|
||||
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 2));
|
||||
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
|
||||
|
||||
let servers = vec![
|
||||
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
|
||||
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
|
||||
];
|
||||
|
||||
let config = FailoverConfig {
|
||||
max_retries: 3,
|
||||
base_retry_delay: Duration::from_millis(10),
|
||||
health_check_interval: Duration::from_secs(30),
|
||||
health_check_timeout: Duration::from_secs(5),
|
||||
circuit_breaker_threshold: 5,
|
||||
};
|
||||
|
||||
let client = FailoverMcpClient::new(servers, config);
|
||||
|
||||
// Should eventually succeed after retries
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
// After 2 failures and 1 success, should get the tool
|
||||
assert!(tools[0].name.contains("test_tool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_failover_to_backup() {
|
||||
// Primary always fails, backup always succeeds
|
||||
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999));
|
||||
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
|
||||
|
||||
let servers = vec![
|
||||
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
|
||||
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
|
||||
];
|
||||
|
||||
let config = FailoverConfig {
|
||||
max_retries: 5,
|
||||
base_retry_delay: Duration::from_millis(5),
|
||||
health_check_interval: Duration::from_secs(30),
|
||||
health_check_timeout: Duration::from_secs(5),
|
||||
circuit_breaker_threshold: 3,
|
||||
};
|
||||
|
||||
let client = FailoverMcpClient::new(servers, config);
|
||||
|
||||
// Should failover to backup after exhausting retries on primary
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0].name, "test_tool_backup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_health_tracking() {
|
||||
let client = Arc::new(MockMcpClient::always_healthy("test"));
|
||||
let entry = ServerEntry::new("test".to_string(), client, 1);
|
||||
|
||||
// Initial state should be healthy
|
||||
assert!(entry.is_available().await);
|
||||
assert_eq!(entry.get_health().await, ServerHealth::Healthy);
|
||||
|
||||
// Mark as degraded
|
||||
entry.mark_degraded().await;
|
||||
assert!(!entry.is_available().await);
|
||||
match entry.get_health().await {
|
||||
ServerHealth::Degraded { .. } => {}
|
||||
_ => panic!("Expected Degraded state"),
|
||||
}
|
||||
|
||||
// Mark as down
|
||||
entry.mark_down().await;
|
||||
assert!(!entry.is_available().await);
|
||||
match entry.get_health().await {
|
||||
ServerHealth::Down { .. } => {}
|
||||
_ => panic!("Expected Down state"),
|
||||
}
|
||||
|
||||
// Recover to healthy
|
||||
entry.mark_healthy().await;
|
||||
assert!(entry.is_available().await);
|
||||
assert_eq!(entry.get_health().await, ServerHealth::Healthy);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_all() {
|
||||
let healthy = Arc::new(MockMcpClient::always_healthy("healthy"));
|
||||
let unhealthy = Arc::new(MockMcpClient::fail_n_times("unhealthy", 999));
|
||||
|
||||
let servers = vec![
|
||||
ServerEntry::new("healthy".to_string(), healthy as Arc<dyn McpClient>, 1),
|
||||
ServerEntry::new("unhealthy".to_string(), unhealthy as Arc<dyn McpClient>, 2),
|
||||
];
|
||||
|
||||
let client = FailoverMcpClient::with_servers(servers);
|
||||
|
||||
// Run health check
|
||||
client.health_check_all().await;
|
||||
|
||||
// Give spawned tasks time to complete
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Check server status
|
||||
let status = client.get_server_status().await;
|
||||
assert_eq!(status.len(), 2);
|
||||
|
||||
// Healthy server should be healthy
|
||||
let healthy_status = status.iter().find(|(name, _)| name == "healthy").unwrap();
|
||||
assert_eq!(healthy_status.1, ServerHealth::Healthy);
|
||||
|
||||
// Unhealthy server should be down
|
||||
let unhealthy_status = status.iter().find(|(name, _)| name == "unhealthy").unwrap();
|
||||
match unhealthy_status.1 {
|
||||
ServerHealth::Down { .. } => {}
|
||||
_ => panic!("Expected unhealthy server to be Down"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_call_tool_failover() {
|
||||
// Primary fails, backup succeeds
|
||||
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999));
|
||||
let backup = Arc::new(MockMcpClient::always_healthy("backup"));
|
||||
|
||||
let servers = vec![
|
||||
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
|
||||
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
|
||||
];
|
||||
|
||||
let config = FailoverConfig {
|
||||
max_retries: 5,
|
||||
base_retry_delay: Duration::from_millis(5),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let client = FailoverMcpClient::new(servers, config);
|
||||
|
||||
// Call a tool - should failover to backup
|
||||
let call = McpToolCall {
|
||||
name: "test_tool".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let response = client.call_tool(call).await.unwrap();
|
||||
assert!(response.success);
|
||||
assert_eq!(response.output["server"], "backup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exponential_backoff() {
|
||||
// Test that retry delays increase exponentially
|
||||
let client = Arc::new(MockMcpClient::fail_n_times("test", 2));
|
||||
let entry = ServerEntry::new("test".to_string(), client, 1);
|
||||
|
||||
let config = FailoverConfig {
|
||||
max_retries: 3,
|
||||
base_retry_delay: Duration::from_millis(10),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let failover = FailoverMcpClient::new(vec![entry], config);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let _ = failover.list_tools().await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// With base delay of 10ms and 2 retries:
|
||||
// Attempt 1: immediate
|
||||
// Attempt 2: 10ms delay (2^0 * 10)
|
||||
// Attempt 3: 20ms delay (2^1 * 10)
|
||||
// Total should be at least 30ms
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(30),
|
||||
"Expected at least 30ms, got {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_servers_configured() {
|
||||
let config = FailoverConfig::default();
|
||||
let client = FailoverMcpClient::new(vec![], config);
|
||||
|
||||
let result = client.list_tools().await;
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(Error::Network(msg)) => assert!(msg.contains("No servers configured")),
|
||||
_ => panic!("Expected Network error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_all_servers_fail() {
|
||||
// Both servers always fail
|
||||
let primary = Arc::new(MockMcpClient::fail_n_times("primary", 999));
|
||||
let backup = Arc::new(MockMcpClient::fail_n_times("backup", 999));
|
||||
|
||||
let servers = vec![
|
||||
ServerEntry::new("primary".to_string(), primary as Arc<dyn McpClient>, 1),
|
||||
ServerEntry::new("backup".to_string(), backup as Arc<dyn McpClient>, 2),
|
||||
];
|
||||
|
||||
let config = FailoverConfig {
|
||||
max_retries: 2,
|
||||
base_retry_delay: Duration::from_millis(5),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let client = FailoverMcpClient::new(servers, config);
|
||||
|
||||
let result = client.list_tools().await;
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(Error::Network(_)) => {} // Expected
|
||||
_ => panic!("Expected Network error"),
|
||||
}
|
||||
}
|
||||
50
crates/owlen-core/tests/prompt_server.rs
Normal file
50
crates/owlen-core/tests/prompt_server.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
//! Integration test for the MCP prompt rendering server.
|
||||
|
||||
use owlen_core::config::McpServerConfig;
|
||||
use owlen_core::mcp::client::RemoteMcpClient;
|
||||
use owlen_core::mcp::{McpToolCall, McpToolResponse};
|
||||
use owlen_core::Result;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_render_prompt_via_external_server() -> Result<()> {
|
||||
// Locate the compiled prompt server binary.
|
||||
let mut binary = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
binary.pop(); // remove `tests`
|
||||
binary.pop(); // remove `owlen-core`
|
||||
binary.push("owlen-mcp-prompt-server");
|
||||
binary.push("target");
|
||||
binary.push("debug");
|
||||
binary.push("owlen-mcp-prompt-server");
|
||||
assert!(
|
||||
binary.exists(),
|
||||
"Prompt server binary not found: {:?}",
|
||||
binary
|
||||
);
|
||||
|
||||
let config = McpServerConfig {
|
||||
name: "prompt_server".into(),
|
||||
command: binary.to_string_lossy().into_owned(),
|
||||
args: Vec::new(),
|
||||
transport: "stdio".into(),
|
||||
env: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let client = RemoteMcpClient::new_with_config(&config)?;
|
||||
|
||||
let call = McpToolCall {
|
||||
name: "render_prompt".into(),
|
||||
arguments: json!({
|
||||
"template_name": "example",
|
||||
"variables": {"name": "Alice", "role": "Tester"}
|
||||
}),
|
||||
};
|
||||
|
||||
let resp: McpToolResponse = client.call_tool(call).await?;
|
||||
assert!(resp.success, "Tool reported failure: {:?}", resp);
|
||||
let output = resp.output.as_str().unwrap_or("");
|
||||
assert!(output.contains("Alice"), "Output missing name: {}", output);
|
||||
assert!(output.contains("Tester"), "Output missing role: {}", output);
|
||||
Ok(())
|
||||
}
|
||||
22
crates/owlen-mcp-code-server/Cargo.toml
Normal file
22
crates/owlen-mcp-code-server/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "owlen-mcp-code-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "MCP server exposing safe code execution tools for Owlen"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
bollard = "0.17"
|
||||
tempfile = "3.0"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
futures = "0.3"
|
||||
|
||||
[lib]
|
||||
name = "owlen_mcp_code_server"
|
||||
path = "src/lib.rs"
|
||||
186
crates/owlen-mcp-code-server/src/lib.rs
Normal file
186
crates/owlen-mcp-code-server/src/lib.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
//! MCP server exposing code execution tools with Docker sandboxing.
|
||||
//!
|
||||
//! This server provides:
|
||||
//! - compile_project: Build projects (Rust, Node.js, Python)
|
||||
//! - run_tests: Execute test suites
|
||||
//! - format_code: Run code formatters
|
||||
//! - lint_code: Run linters
|
||||
|
||||
pub mod sandbox;
|
||||
pub mod tools;
|
||||
|
||||
use owlen_core::mcp::protocol::{
|
||||
methods, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError, RpcErrorResponse,
|
||||
RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
|
||||
};
|
||||
use owlen_core::tools::{Tool, ToolResult};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
|
||||
|
||||
use tools::{CompileProjectTool, FormatCodeTool, LintCodeTool, RunTestsTool};
|
||||
|
||||
/// Tool registry for the code server
|
||||
#[allow(dead_code)]
|
||||
struct ToolRegistry {
|
||||
tools: HashMap<String, Box<dyn Tool + Send + Sync>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ToolRegistry {
|
||||
fn new() -> Self {
|
||||
let mut tools: HashMap<String, Box<dyn Tool + Send + Sync>> = HashMap::new();
|
||||
tools.insert(
|
||||
"compile_project".to_string(),
|
||||
Box::new(CompileProjectTool::new()),
|
||||
);
|
||||
tools.insert("run_tests".to_string(), Box::new(RunTestsTool::new()));
|
||||
tools.insert("format_code".to_string(), Box::new(FormatCodeTool::new()));
|
||||
tools.insert("lint_code".to_string(), Box::new(LintCodeTool::new()));
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
fn list_tools(&self) -> Vec<owlen_core::mcp::McpToolDescriptor> {
|
||||
self.tools
|
||||
.values()
|
||||
.map(|tool| owlen_core::mcp::McpToolDescriptor {
|
||||
name: tool.name().to_string(),
|
||||
description: tool.description().to_string(),
|
||||
input_schema: tool.schema(),
|
||||
requires_network: tool.requires_network(),
|
||||
requires_filesystem: tool.requires_filesystem(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn execute(&self, name: &str, args: Value) -> Result<ToolResult, String> {
|
||||
self.tools
|
||||
.get(name)
|
||||
.ok_or_else(|| format!("Tool not found: {}", name))?
|
||||
.execute(args)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut stdin = io::BufReader::new(io::stdin());
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
let registry = Arc::new(ToolRegistry::new());
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match stdin.read_line(&mut line).await {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {
|
||||
let req: RpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let err = RpcErrorResponse::new(
|
||||
RequestId::Number(0),
|
||||
RpcError::parse_error(format!("Parse error: {}", e)),
|
||||
);
|
||||
let s = serde_json::to_string(&err)?;
|
||||
stdout.write_all(s.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = handle_request(req.clone(), registry.clone()).await;
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let s = serde_json::to_string(&r)?;
|
||||
stdout.write_all(s.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
let err = RpcErrorResponse::new(req.id.clone(), e);
|
||||
let s = serde_json::to_string(&err)?;
|
||||
stdout.write_all(s.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error reading stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn handle_request(
|
||||
req: RpcRequest,
|
||||
registry: Arc<ToolRegistry>,
|
||||
) -> Result<RpcResponse, RpcError> {
|
||||
match req.method.as_str() {
|
||||
methods::INITIALIZE => {
|
||||
let params: InitializeParams =
|
||||
serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
|
||||
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
|
||||
if !params.protocol_version.eq(PROTOCOL_VERSION) {
|
||||
return Err(RpcError::new(
|
||||
ErrorCode::INVALID_REQUEST,
|
||||
format!(
|
||||
"Incompatible protocol version. Client: {}, Server: {}",
|
||||
params.protocol_version, PROTOCOL_VERSION
|
||||
),
|
||||
));
|
||||
}
|
||||
let result = InitializeResult {
|
||||
protocol_version: PROTOCOL_VERSION.to_string(),
|
||||
server_info: ServerInfo {
|
||||
name: "owlen-mcp-code-server".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: ServerCapabilities {
|
||||
supports_tools: Some(true),
|
||||
supports_resources: Some(false),
|
||||
supports_streaming: Some(false),
|
||||
},
|
||||
};
|
||||
Ok(RpcResponse::new(
|
||||
req.id,
|
||||
serde_json::to_value(result).unwrap(),
|
||||
))
|
||||
}
|
||||
methods::TOOLS_LIST => {
|
||||
let tools = registry.list_tools();
|
||||
Ok(RpcResponse::new(req.id, json!(tools)))
|
||||
}
|
||||
methods::TOOLS_CALL => {
|
||||
let call = serde_json::from_value::<owlen_core::mcp::McpToolCall>(
|
||||
req.params.unwrap_or_else(|| json!({})),
|
||||
)
|
||||
.map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?;
|
||||
|
||||
let result: ToolResult = registry
|
||||
.execute(&call.name, call.arguments)
|
||||
.await
|
||||
.map_err(|e| RpcError::internal_error(format!("Tool execution failed: {}", e)))?;
|
||||
|
||||
let resp = owlen_core::mcp::McpToolResponse {
|
||||
name: call.name,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
metadata: result.metadata,
|
||||
duration_ms: result.duration.as_millis() as u128,
|
||||
};
|
||||
Ok(RpcResponse::new(
|
||||
req.id,
|
||||
serde_json::to_value(resp).unwrap(),
|
||||
))
|
||||
}
|
||||
_ => Err(RpcError::method_not_found(&req.method)),
|
||||
}
|
||||
}
|
||||
250
crates/owlen-mcp-code-server/src/sandbox.rs
Normal file
250
crates/owlen-mcp-code-server/src/sandbox.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
//! Docker-based sandboxing for secure code execution
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
|
||||
WaitContainerOptions,
|
||||
};
|
||||
use bollard::models::{HostConfig, Mount, MountTypeEnum};
|
||||
use bollard::Docker;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Result of executing code in a sandbox
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecutionResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: i64,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
/// Docker-based sandbox executor
|
||||
pub struct Sandbox {
|
||||
docker: Docker,
|
||||
memory_limit: i64,
|
||||
cpu_quota: i64,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl Sandbox {
|
||||
/// Create a new sandbox with default resource limits
|
||||
pub fn new() -> Result<Self> {
|
||||
let docker =
|
||||
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
|
||||
|
||||
Ok(Self {
|
||||
docker,
|
||||
memory_limit: 512 * 1024 * 1024, // 512MB
|
||||
cpu_quota: 50000, // 50% of one core
|
||||
timeout_secs: 30,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a command in a sandboxed container
|
||||
pub async fn execute(
|
||||
&self,
|
||||
image: &str,
|
||||
cmd: &[&str],
|
||||
workspace: Option<&Path>,
|
||||
env: HashMap<String, String>,
|
||||
) -> Result<ExecutionResult> {
|
||||
let container_name = format!("owlen-sandbox-{}", uuid::Uuid::new_v4());
|
||||
|
||||
// Prepare volume mount if workspace provided
|
||||
let mounts = if let Some(ws) = workspace {
|
||||
vec![Mount {
|
||||
target: Some("/workspace".to_string()),
|
||||
source: Some(ws.to_string_lossy().to_string()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(false),
|
||||
..Default::default()
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Create container config
|
||||
let host_config = HostConfig {
|
||||
memory: Some(self.memory_limit),
|
||||
cpu_quota: Some(self.cpu_quota),
|
||||
network_mode: Some("none".to_string()), // No network access
|
||||
mounts: Some(mounts),
|
||||
auto_remove: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = Config {
|
||||
image: Some(image.to_string()),
|
||||
cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
env: Some(env.iter().map(|(k, v)| format!("{}={}", k, v)).collect()),
|
||||
host_config: Some(host_config),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Create container
|
||||
let container = self
|
||||
.docker
|
||||
.create_container(
|
||||
Some(CreateContainerOptions {
|
||||
name: container_name.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
config,
|
||||
)
|
||||
.await
|
||||
.context("Failed to create container")?;
|
||||
|
||||
// Start container
|
||||
self.docker
|
||||
.start_container(&container.id, None::<StartContainerOptions<String>>)
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
// Wait for container with timeout
|
||||
let wait_result =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), async {
|
||||
let mut wait_stream = self
|
||||
.docker
|
||||
.wait_container(&container.id, None::<WaitContainerOptions<String>>);
|
||||
|
||||
use futures::StreamExt;
|
||||
if let Some(result) = wait_stream.next().await {
|
||||
result
|
||||
} else {
|
||||
Err(bollard::errors::Error::IOError {
|
||||
err: std::io::Error::other("Container wait stream ended unexpectedly"),
|
||||
})
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let (exit_code, timed_out) = match wait_result {
|
||||
Ok(Ok(result)) => (result.status_code, false),
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("Container wait error: {}", e);
|
||||
(1, false)
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout - kill the container
|
||||
let _ = self
|
||||
.docker
|
||||
.kill_container(
|
||||
&container.id,
|
||||
None::<bollard::container::KillContainerOptions<String>>,
|
||||
)
|
||||
.await;
|
||||
(124, true)
|
||||
}
|
||||
};
|
||||
|
||||
// Get logs
|
||||
let logs = self.docker.logs(
|
||||
&container.id,
|
||||
Some(bollard::container::LogsOptions::<String> {
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
|
||||
use futures::StreamExt;
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
|
||||
let log_result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
let mut logs = logs;
|
||||
while let Some(log) = logs.next().await {
|
||||
match log {
|
||||
Ok(bollard::container::LogOutput::StdOut { message }) => {
|
||||
stdout.push_str(&String::from_utf8_lossy(&message));
|
||||
}
|
||||
Ok(bollard::container::LogOutput::StdErr { message }) => {
|
||||
stderr.push_str(&String::from_utf8_lossy(&message));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if log_result.is_err() {
|
||||
eprintln!("Timeout reading container logs");
|
||||
}
|
||||
|
||||
// Remove container (auto_remove should handle this, but be explicit)
|
||||
let _ = self
|
||||
.docker
|
||||
.remove_container(
|
||||
&container.id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ExecutionResult {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code,
|
||||
timed_out,
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute in a Rust environment
|
||||
pub async fn execute_rust(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
|
||||
self.execute("rust:1.75-slim", cmd, Some(workspace), HashMap::new())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute in a Python environment
|
||||
pub async fn execute_python(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
|
||||
self.execute("python:3.11-slim", cmd, Some(workspace), HashMap::new())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute in a Node.js environment
|
||||
pub async fn execute_node(&self, workspace: &Path, cmd: &[&str]) -> Result<ExecutionResult> {
|
||||
self.execute("node:20-slim", cmd, Some(workspace), HashMap::new())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Sandbox {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to create default sandbox")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires Docker daemon
|
||||
async fn test_sandbox_rust_compile() {
|
||||
let sandbox = Sandbox::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a simple Rust project
|
||||
std::fs::write(
|
||||
temp_dir.path().join("main.rs"),
|
||||
"fn main() { println!(\"Hello from sandbox!\"); }",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = sandbox
|
||||
.execute_rust(temp_dir.path(), &["rustc", "main.rs"])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.exit_code, 0);
|
||||
assert!(!result.timed_out);
|
||||
}
|
||||
}
|
||||
417
crates/owlen-mcp-code-server/src/tools.rs
Normal file
417
crates/owlen-mcp-code-server/src/tools.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
//! Code execution tools using Docker sandboxing
|
||||
|
||||
use crate::sandbox::Sandbox;
|
||||
use async_trait::async_trait;
|
||||
use owlen_core::tools::{Tool, ToolResult};
|
||||
use owlen_core::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Tool for compiling projects (Rust, Node.js, Python)
|
||||
pub struct CompileProjectTool {
|
||||
sandbox: Sandbox,
|
||||
}
|
||||
|
||||
impl Default for CompileProjectTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CompileProjectTool {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sandbox: Sandbox::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CompileProjectTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"compile_project"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Compile a project (Rust, Node.js, Python). Detects project type automatically."
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the project root"
|
||||
},
|
||||
"project_type": {
|
||||
"type": "string",
|
||||
"enum": ["rust", "node", "python"],
|
||||
"description": "Project type (auto-detected if not specified)"
|
||||
}
|
||||
},
|
||||
"required": ["project_path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let project_path = args
|
||||
.get("project_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
|
||||
|
||||
let path = PathBuf::from(project_path);
|
||||
if !path.exists() {
|
||||
return Ok(ToolResult::error("Project path does not exist"));
|
||||
}
|
||||
|
||||
// Detect project type
|
||||
let project_type = if let Some(pt) = args.get("project_type").and_then(|v| v.as_str()) {
|
||||
pt.to_string()
|
||||
} else if path.join("Cargo.toml").exists() {
|
||||
"rust".to_string()
|
||||
} else if path.join("package.json").exists() {
|
||||
"node".to_string()
|
||||
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
|
||||
"python".to_string()
|
||||
} else {
|
||||
return Ok(ToolResult::error("Could not detect project type"));
|
||||
};
|
||||
|
||||
// Execute compilation
|
||||
let result = match project_type.as_str() {
|
||||
"rust" => self.sandbox.execute_rust(&path, &["cargo", "build"]).await,
|
||||
"node" => {
|
||||
self.sandbox
|
||||
.execute_node(&path, &["npm", "run", "build"])
|
||||
.await
|
||||
}
|
||||
"python" => {
|
||||
// Python typically doesn't need compilation, but we can check syntax
|
||||
self.sandbox
|
||||
.execute_python(&path, &["python", "-m", "compileall", "."])
|
||||
.await
|
||||
}
|
||||
_ => return Ok(ToolResult::error("Unsupported project type")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(exec_result) => {
|
||||
if exec_result.timed_out {
|
||||
Ok(ToolResult::error("Compilation timed out"))
|
||||
} else if exec_result.exit_code == 0 {
|
||||
Ok(ToolResult::success(json!({
|
||||
"success": true,
|
||||
"stdout": exec_result.stdout,
|
||||
"stderr": exec_result.stderr,
|
||||
"project_type": project_type
|
||||
})))
|
||||
} else {
|
||||
Ok(ToolResult::success(json!({
|
||||
"success": false,
|
||||
"exit_code": exec_result.exit_code,
|
||||
"stdout": exec_result.stdout,
|
||||
"stderr": exec_result.stderr,
|
||||
"project_type": project_type
|
||||
})))
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(ToolResult::error(&format!("Compilation failed: {}", e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for running test suites
|
||||
pub struct RunTestsTool {
|
||||
sandbox: Sandbox,
|
||||
}
|
||||
|
||||
impl Default for RunTestsTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RunTestsTool {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sandbox: Sandbox::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RunTestsTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"run_tests"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run tests for a project (Rust, Node.js, Python)"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the project root"
|
||||
},
|
||||
"test_filter": {
|
||||
"type": "string",
|
||||
"description": "Optional test filter/pattern"
|
||||
}
|
||||
},
|
||||
"required": ["project_path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let project_path = args
|
||||
.get("project_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
|
||||
|
||||
let path = PathBuf::from(project_path);
|
||||
if !path.exists() {
|
||||
return Ok(ToolResult::error("Project path does not exist"));
|
||||
}
|
||||
|
||||
let test_filter = args.get("test_filter").and_then(|v| v.as_str());
|
||||
|
||||
// Detect project type and run tests
|
||||
let result = if path.join("Cargo.toml").exists() {
|
||||
let cmd = if let Some(filter) = test_filter {
|
||||
vec!["cargo", "test", filter]
|
||||
} else {
|
||||
vec!["cargo", "test"]
|
||||
};
|
||||
self.sandbox.execute_rust(&path, &cmd).await
|
||||
} else if path.join("package.json").exists() {
|
||||
self.sandbox.execute_node(&path, &["npm", "test"]).await
|
||||
} else if path.join("pytest.ini").exists()
|
||||
|| path.join("setup.py").exists()
|
||||
|| path.join("pyproject.toml").exists()
|
||||
{
|
||||
let cmd = if let Some(filter) = test_filter {
|
||||
vec!["pytest", "-k", filter]
|
||||
} else {
|
||||
vec!["pytest"]
|
||||
};
|
||||
self.sandbox.execute_python(&path, &cmd).await
|
||||
} else {
|
||||
return Ok(ToolResult::error("Could not detect test framework"));
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(exec_result) => Ok(ToolResult::success(json!({
|
||||
"success": exec_result.exit_code == 0 && !exec_result.timed_out,
|
||||
"exit_code": exec_result.exit_code,
|
||||
"stdout": exec_result.stdout,
|
||||
"stderr": exec_result.stderr,
|
||||
"timed_out": exec_result.timed_out
|
||||
}))),
|
||||
Err(e) => Ok(ToolResult::error(&format!("Tests failed to run: {}", e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for formatting code
|
||||
pub struct FormatCodeTool {
|
||||
sandbox: Sandbox,
|
||||
}
|
||||
|
||||
impl Default for FormatCodeTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FormatCodeTool {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sandbox: Sandbox::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FormatCodeTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"format_code"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Format code using project-appropriate formatter (rustfmt, prettier, black)"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the project root"
|
||||
},
|
||||
"check_only": {
|
||||
"type": "boolean",
|
||||
"description": "Only check formatting without modifying files",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["project_path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let project_path = args
|
||||
.get("project_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
|
||||
|
||||
let path = PathBuf::from(project_path);
|
||||
if !path.exists() {
|
||||
return Ok(ToolResult::error("Project path does not exist"));
|
||||
}
|
||||
|
||||
let check_only = args
|
||||
.get("check_only")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Detect project type and run formatter
|
||||
let result = if path.join("Cargo.toml").exists() {
|
||||
let cmd = if check_only {
|
||||
vec!["cargo", "fmt", "--", "--check"]
|
||||
} else {
|
||||
vec!["cargo", "fmt"]
|
||||
};
|
||||
self.sandbox.execute_rust(&path, &cmd).await
|
||||
} else if path.join("package.json").exists() {
|
||||
let cmd = if check_only {
|
||||
vec!["npx", "prettier", "--check", "."]
|
||||
} else {
|
||||
vec!["npx", "prettier", "--write", "."]
|
||||
};
|
||||
self.sandbox.execute_node(&path, &cmd).await
|
||||
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
|
||||
let cmd = if check_only {
|
||||
vec!["black", "--check", "."]
|
||||
} else {
|
||||
vec!["black", "."]
|
||||
};
|
||||
self.sandbox.execute_python(&path, &cmd).await
|
||||
} else {
|
||||
return Ok(ToolResult::error("Could not detect project type"));
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(exec_result) => Ok(ToolResult::success(json!({
|
||||
"success": exec_result.exit_code == 0,
|
||||
"formatted": !check_only && exec_result.exit_code == 0,
|
||||
"stdout": exec_result.stdout,
|
||||
"stderr": exec_result.stderr
|
||||
}))),
|
||||
Err(e) => Ok(ToolResult::error(&format!("Formatting failed: {}", e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for linting code
|
||||
pub struct LintCodeTool {
|
||||
sandbox: Sandbox,
|
||||
}
|
||||
|
||||
impl Default for LintCodeTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LintCodeTool {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sandbox: Sandbox::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LintCodeTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"lint_code"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Lint code using project-appropriate linter (clippy, eslint, pylint)"
|
||||
}
|
||||
|
||||
fn schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the project root"
|
||||
},
|
||||
"fix": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically fix issues if possible",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["project_path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> Result<ToolResult> {
|
||||
let project_path = args
|
||||
.get("project_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| owlen_core::Error::InvalidInput("Missing project_path".into()))?;
|
||||
|
||||
let path = PathBuf::from(project_path);
|
||||
if !path.exists() {
|
||||
return Ok(ToolResult::error("Project path does not exist"));
|
||||
}
|
||||
|
||||
let fix = args.get("fix").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
// Detect project type and run linter
|
||||
let result = if path.join("Cargo.toml").exists() {
|
||||
let cmd = if fix {
|
||||
vec!["cargo", "clippy", "--fix", "--allow-dirty"]
|
||||
} else {
|
||||
vec!["cargo", "clippy"]
|
||||
};
|
||||
self.sandbox.execute_rust(&path, &cmd).await
|
||||
} else if path.join("package.json").exists() {
|
||||
let cmd = if fix {
|
||||
vec!["npx", "eslint", ".", "--fix"]
|
||||
} else {
|
||||
vec!["npx", "eslint", "."]
|
||||
};
|
||||
self.sandbox.execute_node(&path, &cmd).await
|
||||
} else if path.join("setup.py").exists() || path.join("pyproject.toml").exists() {
|
||||
// pylint doesn't have auto-fix
|
||||
self.sandbox.execute_python(&path, &["pylint", "."]).await
|
||||
} else {
|
||||
return Ok(ToolResult::error("Could not detect project type"));
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(exec_result) => {
|
||||
let issues_found = exec_result.exit_code != 0;
|
||||
Ok(ToolResult::success(json!({
|
||||
"success": true,
|
||||
"issues_found": issues_found,
|
||||
"exit_code": exec_result.exit_code,
|
||||
"stdout": exec_result.stdout,
|
||||
"stderr": exec_result.stderr
|
||||
})))
|
||||
}
|
||||
Err(e) => Ok(ToolResult::error(&format!("Linting failed: {}", e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,8 +107,10 @@ fn resources_list_descriptor() -> McpToolDescriptor {
|
||||
}
|
||||
|
||||
async fn handle_generate_text(args: GenerateTextArgs) -> Result<String, RpcError> {
|
||||
// Create provider with default local Ollama URL
|
||||
let provider = OllamaProvider::new("http://localhost:11434")
|
||||
// Create provider with Ollama URL from environment or default to localhost
|
||||
let ollama_url =
|
||||
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let provider = OllamaProvider::new(&ollama_url)
|
||||
.map_err(|e| RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e)))?;
|
||||
|
||||
let parameters = ChatParameters {
|
||||
@@ -190,7 +192,9 @@ async fn handle_request(req: &RpcRequest) -> Result<Value, RpcError> {
|
||||
// New method to list available Ollama models via the provider.
|
||||
methods::MODELS_LIST => {
|
||||
// Reuse the provider instance for model listing.
|
||||
let provider = OllamaProvider::new("http://localhost:11434").map_err(|e| {
|
||||
let ollama_url =
|
||||
env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let provider = OllamaProvider::new(&ollama_url).map_err(|e| {
|
||||
RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e))
|
||||
})?;
|
||||
let models = provider
|
||||
@@ -377,7 +381,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
// Initialize Ollama provider and start streaming
|
||||
let provider = match OllamaProvider::new("http://localhost:11434") {
|
||||
let ollama_url = env::var("OLLAMA_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let provider = match OllamaProvider::new(&ollama_url) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let err_resp = RpcErrorResponse::new(
|
||||
|
||||
21
crates/owlen-mcp-prompt-server/Cargo.toml
Normal file
21
crates/owlen-mcp-prompt-server/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "owlen-mcp-prompt-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "MCP server that renders prompt templates (YAML) for Owlen"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
anyhow = "1.0"
|
||||
handlebars = "6.0"
|
||||
dirs = "5.0"
|
||||
futures = "0.3"
|
||||
|
||||
[lib]
|
||||
name = "owlen_mcp_prompt_server"
|
||||
path = "src/lib.rs"
|
||||
407
crates/owlen-mcp-prompt-server/src/lib.rs
Normal file
407
crates/owlen-mcp-prompt-server/src/lib.rs
Normal file
@@ -0,0 +1,407 @@
|
||||
//! MCP server for rendering prompt templates with YAML storage and Handlebars rendering.
|
||||
//!
|
||||
//! Templates are stored in `~/.config/owlen/prompts/` as YAML files.
|
||||
//! Provides full Handlebars templating support for dynamic prompt generation.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use handlebars::Handlebars;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use owlen_core::mcp::protocol::{
|
||||
methods, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError, RpcErrorResponse,
|
||||
RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
|
||||
};
|
||||
use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
|
||||
|
||||
/// Prompt template definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptTemplate {
|
||||
/// Template name
|
||||
pub name: String,
|
||||
/// Template version
|
||||
pub version: String,
|
||||
/// Optional mode restriction
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
/// Handlebars template content
|
||||
pub template: String,
|
||||
/// Template description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// Prompt server managing templates
|
||||
pub struct PromptServer {
|
||||
templates: Arc<RwLock<HashMap<String, PromptTemplate>>>,
|
||||
handlebars: Handlebars<'static>,
|
||||
templates_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PromptServer {
|
||||
/// Create a new prompt server
|
||||
pub fn new() -> Result<Self> {
|
||||
let templates_dir = Self::get_templates_dir()?;
|
||||
|
||||
// Create templates directory if it doesn't exist
|
||||
if !templates_dir.exists() {
|
||||
fs::create_dir_all(&templates_dir)?;
|
||||
Self::create_default_templates(&templates_dir)?;
|
||||
}
|
||||
|
||||
let mut server = Self {
|
||||
templates: Arc::new(RwLock::new(HashMap::new())),
|
||||
handlebars: Handlebars::new(),
|
||||
templates_dir,
|
||||
};
|
||||
|
||||
// Load all templates
|
||||
server.load_templates()?;
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// Get the templates directory path
|
||||
fn get_templates_dir() -> Result<PathBuf> {
|
||||
let config_dir = dirs::config_dir().context("Could not determine config directory")?;
|
||||
Ok(config_dir.join("owlen").join("prompts"))
|
||||
}
|
||||
|
||||
/// Create default template examples
|
||||
fn create_default_templates(dir: &Path) -> Result<()> {
|
||||
let chat_mode_system = PromptTemplate {
|
||||
name: "chat_mode_system".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
mode: Some("chat".to_string()),
|
||||
description: Some("System prompt for chat mode".to_string()),
|
||||
template: r#"You are Owlen, a helpful AI assistant. You have access to these tools:
|
||||
{{#each tools}}
|
||||
- {{name}}: {{description}}
|
||||
{{/each}}
|
||||
|
||||
Use the ReAct pattern:
|
||||
THOUGHT: Your reasoning
|
||||
ACTION: tool_name
|
||||
ACTION_INPUT: {"param": "value"}
|
||||
|
||||
When you have enough information:
|
||||
FINAL_ANSWER: Your response"#
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let code_mode_system = PromptTemplate {
|
||||
name: "code_mode_system".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
mode: Some("code".to_string()),
|
||||
description: Some("System prompt for code mode".to_string()),
|
||||
template: r#"You are Owlen in code mode, with full development capabilities. You have access to:
|
||||
{{#each tools}}
|
||||
- {{name}}: {{description}}
|
||||
{{/each}}
|
||||
|
||||
Use the ReAct pattern to solve coding tasks:
|
||||
THOUGHT: Analyze what needs to be done
|
||||
ACTION: tool_name (compile_project, run_tests, format_code, lint_code, etc.)
|
||||
ACTION_INPUT: {"param": "value"}
|
||||
|
||||
Continue iterating until the task is complete, then provide:
|
||||
FINAL_ANSWER: Summary of what was done"#
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
// Save templates
|
||||
let chat_path = dir.join("chat_mode_system.yaml");
|
||||
let code_path = dir.join("code_mode_system.yaml");
|
||||
|
||||
fs::write(chat_path, serde_yaml::to_string(&chat_mode_system)?)?;
|
||||
fs::write(code_path, serde_yaml::to_string(&code_mode_system)?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all templates from the templates directory
|
||||
fn load_templates(&mut self) -> Result<()> {
|
||||
let entries = fs::read_dir(&self.templates_dir)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("yaml")
|
||||
|| path.extension().and_then(|s| s.to_str()) == Some("yml")
|
||||
{
|
||||
match self.load_template(&path) {
|
||||
Ok(template) => {
|
||||
// Register with Handlebars
|
||||
if let Err(e) = self
|
||||
.handlebars
|
||||
.register_template_string(&template.name, &template.template)
|
||||
{
|
||||
eprintln!(
|
||||
"Warning: Failed to register template {}: {}",
|
||||
template.name, e
|
||||
);
|
||||
} else {
|
||||
let mut templates = futures::executor::block_on(self.templates.write());
|
||||
templates.insert(template.name.clone(), template);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to load template {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a single template from file
|
||||
fn load_template(&self, path: &Path) -> Result<PromptTemplate> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let template: PromptTemplate = serde_yaml::from_str(&content)?;
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// Get a template by name
|
||||
pub async fn get_template(&self, name: &str) -> Option<PromptTemplate> {
|
||||
let templates = self.templates.read().await;
|
||||
templates.get(name).cloned()
|
||||
}
|
||||
|
||||
/// List all available templates
|
||||
pub async fn list_templates(&self) -> Vec<String> {
|
||||
let templates = self.templates.read().await;
|
||||
templates.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Render a template with given variables
|
||||
pub fn render_template(&self, name: &str, vars: &Value) -> Result<String> {
|
||||
self.handlebars
|
||||
.render(name, vars)
|
||||
.context("Failed to render template")
|
||||
}
|
||||
|
||||
/// Reload all templates from disk
|
||||
pub async fn reload_templates(&mut self) -> Result<()> {
|
||||
{
|
||||
let mut templates = self.templates.write().await;
|
||||
templates.clear();
|
||||
}
|
||||
self.handlebars = Handlebars::new();
|
||||
self.load_templates()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut stdin = io::BufReader::new(io::stdin());
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
let server = Arc::new(tokio::sync::Mutex::new(PromptServer::new()?));
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match stdin.read_line(&mut line).await {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {
|
||||
let req: RpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let err = RpcErrorResponse::new(
|
||||
RequestId::Number(0),
|
||||
RpcError::parse_error(format!("Parse error: {}", e)),
|
||||
);
|
||||
let s = serde_json::to_string(&err)?;
|
||||
stdout.write_all(s.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = handle_request(req.clone(), server.clone()).await;
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let s = serde_json::to_string(&r)?;
|
||||
stdout.write_all(s.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
}
|
||||
Err(e) => {
|
||||
let err = RpcErrorResponse::new(req.id.clone(), e);
|
||||
let s = serde_json::to_string(&err)?;
|
||||
stdout.write_all(s.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error reading stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn handle_request(
|
||||
req: RpcRequest,
|
||||
server: Arc<tokio::sync::Mutex<PromptServer>>,
|
||||
) -> Result<RpcResponse, RpcError> {
|
||||
match req.method.as_str() {
|
||||
methods::INITIALIZE => {
|
||||
let params: InitializeParams =
|
||||
serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
|
||||
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
|
||||
if !params.protocol_version.eq(PROTOCOL_VERSION) {
|
||||
return Err(RpcError::new(
|
||||
ErrorCode::INVALID_REQUEST,
|
||||
format!(
|
||||
"Incompatible protocol version. Client: {}, Server: {}",
|
||||
params.protocol_version, PROTOCOL_VERSION
|
||||
),
|
||||
));
|
||||
}
|
||||
let result = InitializeResult {
|
||||
protocol_version: PROTOCOL_VERSION.to_string(),
|
||||
server_info: ServerInfo {
|
||||
name: "owlen-mcp-prompt-server".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
capabilities: ServerCapabilities {
|
||||
supports_tools: Some(true),
|
||||
supports_resources: Some(false),
|
||||
supports_streaming: Some(false),
|
||||
},
|
||||
};
|
||||
Ok(RpcResponse::new(
|
||||
req.id,
|
||||
serde_json::to_value(result).unwrap(),
|
||||
))
|
||||
}
|
||||
methods::TOOLS_LIST => {
|
||||
let tools = vec![
|
||||
McpToolDescriptor {
|
||||
name: "get_prompt".to_string(),
|
||||
description: "Retrieve a prompt template by name".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Template name"}
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
requires_network: false,
|
||||
requires_filesystem: vec![],
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "render_prompt".to_string(),
|
||||
description: "Render a prompt template with Handlebars variables".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Template name"},
|
||||
"vars": {"type": "object", "description": "Variables for Handlebars rendering"}
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
requires_network: false,
|
||||
requires_filesystem: vec![],
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "list_prompts".to_string(),
|
||||
description: "List all available prompt templates".to_string(),
|
||||
input_schema: json!({"type": "object", "properties": {}}),
|
||||
requires_network: false,
|
||||
requires_filesystem: vec![],
|
||||
},
|
||||
McpToolDescriptor {
|
||||
name: "reload_prompts".to_string(),
|
||||
description: "Reload all prompts from disk".to_string(),
|
||||
input_schema: json!({"type": "object", "properties": {}}),
|
||||
requires_network: false,
|
||||
requires_filesystem: vec![],
|
||||
},
|
||||
];
|
||||
Ok(RpcResponse::new(req.id, json!(tools)))
|
||||
}
|
||||
methods::TOOLS_CALL => {
|
||||
let call: McpToolCall = serde_json::from_value(req.params.unwrap_or_else(|| json!({})))
|
||||
.map_err(|e| RpcError::invalid_params(format!("Invalid tool call: {}", e)))?;
|
||||
|
||||
let result = match call.name.as_str() {
|
||||
"get_prompt" => {
|
||||
let name = call
|
||||
.arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?;
|
||||
|
||||
let srv = server.lock().await;
|
||||
match srv.get_template(name).await {
|
||||
Some(template) => {
|
||||
json!({"success": true, "template": serde_json::to_value(template).unwrap()})
|
||||
}
|
||||
None => json!({"success": false, "error": "Template not found"}),
|
||||
}
|
||||
}
|
||||
"render_prompt" => {
|
||||
let name = call
|
||||
.arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RpcError::invalid_params("Missing 'name' parameter"))?;
|
||||
|
||||
let default_vars = json!({});
|
||||
let vars = call.arguments.get("vars").unwrap_or(&default_vars);
|
||||
|
||||
let srv = server.lock().await;
|
||||
match srv.render_template(name, vars) {
|
||||
Ok(rendered) => json!({"success": true, "rendered": rendered}),
|
||||
Err(e) => json!({"success": false, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
"list_prompts" => {
|
||||
let srv = server.lock().await;
|
||||
let templates = srv.list_templates().await;
|
||||
json!({"success": true, "templates": templates})
|
||||
}
|
||||
"reload_prompts" => {
|
||||
let mut srv = server.lock().await;
|
||||
match srv.reload_templates().await {
|
||||
Ok(_) => json!({"success": true, "message": "Prompts reloaded"}),
|
||||
Err(e) => json!({"success": false, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
_ => return Err(RpcError::method_not_found(&call.name)),
|
||||
};
|
||||
|
||||
let resp = McpToolResponse {
|
||||
name: call.name,
|
||||
success: result
|
||||
.get("success")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
output: result,
|
||||
metadata: HashMap::new(),
|
||||
duration_ms: 0,
|
||||
};
|
||||
|
||||
Ok(RpcResponse::new(
|
||||
req.id,
|
||||
serde_json::to_value(resp).unwrap(),
|
||||
))
|
||||
}
|
||||
_ => Err(RpcError::method_not_found(&req.method)),
|
||||
}
|
||||
}
|
||||
3
crates/owlen-mcp-prompt-server/templates/example.yaml
Normal file
3
crates/owlen-mcp-prompt-server/templates/example.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
prompt: |
|
||||
Hello {{name}}!
|
||||
Your role is: {{role}}.
|
||||
@@ -10,8 +10,7 @@ description = "Terminal User Interface for OWLEN LLM client"
|
||||
|
||||
[dependencies]
|
||||
owlen-core = { path = "../owlen-core" }
|
||||
owlen-ollama = { path = "../owlen-ollama" }
|
||||
# Removed circular dependency on `owlen-cli`. The TUI no longer directly depends on the CLI crate.
|
||||
# Removed owlen-ollama dependency - all providers now accessed via MCP architecture (Phase 10)
|
||||
|
||||
# TUI framework
|
||||
ratatui = { workspace = true }
|
||||
|
||||
@@ -311,7 +311,7 @@ impl ChatApp {
|
||||
pub async fn set_mode(&mut self, mode: owlen_core::mode::Mode) {
|
||||
self.operating_mode = mode;
|
||||
self.status = format!("Switched to {} mode", mode);
|
||||
// TODO: Update MCP client mode when MCP integration is fully implemented
|
||||
// Mode switching is handled by the SessionController's tool filtering
|
||||
}
|
||||
|
||||
pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] {
|
||||
@@ -2320,14 +2320,14 @@ impl ChatApp {
|
||||
}
|
||||
|
||||
async fn collect_models_from_all_providers(&self) -> (Vec<ModelInfo>, Vec<String>) {
|
||||
let (provider_entries, general) = {
|
||||
let provider_entries = {
|
||||
let config = self.controller.config();
|
||||
let entries: Vec<(String, ProviderConfig)> = config
|
||||
.providers
|
||||
.iter()
|
||||
.map(|(name, cfg)| (name.clone(), cfg.clone()))
|
||||
.collect();
|
||||
(entries, config.general.clone())
|
||||
entries
|
||||
};
|
||||
|
||||
let mut models = Vec::new();
|
||||
@@ -2339,10 +2339,54 @@ impl ChatApp {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Separate handling based on provider type.
|
||||
if provider_type == "ollama" {
|
||||
// Local Ollama – communicate via the MCP LLM server.
|
||||
match RemoteMcpClient::new() {
|
||||
// All providers communicate via MCP LLM server (Phase 10).
|
||||
// For cloud providers, the URL is passed via the provider config.
|
||||
let client_result = if provider_type == "ollama-cloud" {
|
||||
// Cloud Ollama - create MCP client with custom URL via env var
|
||||
use owlen_core::config::McpServerConfig;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.ok();
|
||||
|
||||
let binary_path = workspace_root.and_then(|root| {
|
||||
let candidates = [
|
||||
"target/debug/owlen-mcp-llm-server",
|
||||
"target/release/owlen-mcp-llm-server",
|
||||
];
|
||||
candidates
|
||||
.iter()
|
||||
.map(|rel| root.join(rel))
|
||||
.find(|p| p.exists())
|
||||
});
|
||||
|
||||
if let Some(path) = binary_path {
|
||||
let mut env_vars = HashMap::new();
|
||||
if let Some(url) = &provider_cfg.base_url {
|
||||
env_vars.insert("OLLAMA_URL".to_string(), url.clone());
|
||||
}
|
||||
|
||||
let config = McpServerConfig {
|
||||
name: name.clone(),
|
||||
command: path.to_string_lossy().into_owned(),
|
||||
args: Vec::new(),
|
||||
transport: "stdio".to_string(),
|
||||
env: env_vars,
|
||||
};
|
||||
RemoteMcpClient::new_with_config(&config)
|
||||
} else {
|
||||
Err(owlen_core::Error::NotImplemented(
|
||||
"MCP server binary not found".into(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
// Local Ollama - use default MCP client
|
||||
RemoteMcpClient::new()
|
||||
};
|
||||
|
||||
match client_result {
|
||||
Ok(client) => match client.list_models().await {
|
||||
Ok(mut provider_models) => {
|
||||
for model in &mut provider_models {
|
||||
@@ -2354,22 +2398,6 @@ impl ChatApp {
|
||||
},
|
||||
Err(err) => errors.push(format!("{}: {}", name, err)),
|
||||
}
|
||||
} else {
|
||||
// Ollama Cloud – use the direct Ollama provider implementation.
|
||||
use owlen_ollama::OllamaProvider;
|
||||
match OllamaProvider::from_config(&provider_cfg, Some(&general)) {
|
||||
Ok(provider) => match provider.list_models().await {
|
||||
Ok(mut cloud_models) => {
|
||||
for model in &mut cloud_models {
|
||||
model.provider = name.clone();
|
||||
}
|
||||
models.extend(cloud_models);
|
||||
}
|
||||
Err(err) => errors.push(format!("{}: {}", name, err)),
|
||||
},
|
||||
Err(err) => errors.push(format!("{}: {}", name, err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort models alphabetically by name for a predictable UI order
|
||||
@@ -2602,17 +2630,45 @@ impl ChatApp {
|
||||
cfg.clone()
|
||||
};
|
||||
|
||||
let general = self.controller.config().general.clone();
|
||||
// Choose the appropriate provider implementation based on its type.
|
||||
let provider: Arc<dyn owlen_core::provider::Provider> =
|
||||
if provider_cfg.provider_type.eq_ignore_ascii_case("ollama") {
|
||||
// Local Ollama via MCP server.
|
||||
Arc::new(RemoteMcpClient::new()?)
|
||||
// All providers use MCP architecture (Phase 10).
|
||||
// For cloud providers, pass the URL via environment variable.
|
||||
let provider: Arc<dyn owlen_core::provider::Provider> = if provider_cfg
|
||||
.provider_type
|
||||
.eq_ignore_ascii_case("ollama-cloud")
|
||||
{
|
||||
// Cloud Ollama - create MCP client with custom URL
|
||||
use owlen_core::config::McpServerConfig;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()?;
|
||||
|
||||
let binary_path = [
|
||||
"target/debug/owlen-mcp-llm-server",
|
||||
"target/release/owlen-mcp-llm-server",
|
||||
]
|
||||
.iter()
|
||||
.map(|rel| workspace_root.join(rel))
|
||||
.find(|p| p.exists())
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP LLM server binary not found"))?;
|
||||
|
||||
let mut env_vars = HashMap::new();
|
||||
if let Some(url) = &provider_cfg.base_url {
|
||||
env_vars.insert("OLLAMA_URL".to_string(), url.clone());
|
||||
}
|
||||
|
||||
let config = McpServerConfig {
|
||||
name: provider_name.to_string(),
|
||||
command: binary_path.to_string_lossy().into_owned(),
|
||||
args: Vec::new(),
|
||||
transport: "stdio".to_string(),
|
||||
env: env_vars,
|
||||
};
|
||||
Arc::new(RemoteMcpClient::new_with_config(&config)?)
|
||||
} else {
|
||||
// Ollama Cloud – instantiate the direct provider.
|
||||
use owlen_ollama::OllamaProvider;
|
||||
let ollama = OllamaProvider::from_config(&provider_cfg, Some(&general))?;
|
||||
Arc::new(ollama)
|
||||
// Local Ollama via default MCP client
|
||||
Arc::new(RemoteMcpClient::new()?)
|
||||
};
|
||||
|
||||
self.controller.switch_provider(provider).await?;
|
||||
@@ -2815,7 +2871,6 @@ impl ChatApp {
|
||||
model: self.controller.selected_model().to_string(),
|
||||
temperature: Some(0.7),
|
||||
max_tokens: None,
|
||||
max_tool_calls: 20,
|
||||
};
|
||||
|
||||
// Get the provider
|
||||
@@ -2834,18 +2889,18 @@ impl ChatApp {
|
||||
};
|
||||
|
||||
// Create agent executor
|
||||
let executor = AgentExecutor::new(provider, mcp_client, config, None);
|
||||
let executor = AgentExecutor::new(provider, mcp_client, config);
|
||||
|
||||
// Run agent
|
||||
match executor.run(user_message).await {
|
||||
Ok(answer) => {
|
||||
Ok(result) => {
|
||||
self.controller
|
||||
.conversation_mut()
|
||||
.push_assistant_message(answer);
|
||||
.push_assistant_message(result.answer);
|
||||
self.agent_running = false;
|
||||
self.agent_mode = false;
|
||||
self.agent_actions = None;
|
||||
self.status = "Agent completed successfully".to_string();
|
||||
self.status = format!("Agent completed in {} iterations", result.iterations);
|
||||
self.stop_loading_animation();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
177
docs/CHANGELOG_v1.0.md
Normal file
177
docs/CHANGELOG_v1.0.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Changelog for v1.0.0 - MCP-Only Architecture
|
||||
|
||||
## Summary
|
||||
|
||||
Version 1.0.0 marks the completion of the MCP-only architecture migration, removing all legacy code paths and fully embracing the Model Context Protocol for all LLM interactions and tool executions.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### 1. Removed Legacy MCP Mode
|
||||
|
||||
**What changed:**
|
||||
- The `[mcp]` section in `config.toml` no longer accepts a `mode` setting
|
||||
- The `McpMode` enum has been removed from the configuration system
|
||||
- MCP architecture is now always enabled - no option to disable it
|
||||
|
||||
**Migration:**
|
||||
```diff
|
||||
# old config.toml
|
||||
[mcp]
|
||||
-mode = "legacy" # or "enabled"
|
||||
|
||||
# new config.toml
|
||||
[mcp]
|
||||
# MCP is always enabled - no mode setting needed
|
||||
```
|
||||
|
||||
**Code changes:**
|
||||
- `crates/owlen-core/src/config.rs`: Removed `McpMode` enum, simplified `McpSettings`
|
||||
- `crates/owlen-core/src/mcp/factory.rs`: Removed legacy mode handling from `McpClientFactory`
|
||||
- All provider calls now go through MCP clients exclusively
|
||||
|
||||
### 2. Updated MCP Client Factory
|
||||
|
||||
**What changed:**
|
||||
- `McpClientFactory::create()` no longer checks for legacy mode
|
||||
- Automatically falls back to `LocalMcpClient` when no external MCP servers are configured
|
||||
- Improved error messages for server connection failures
|
||||
|
||||
**Before:**
|
||||
```rust
|
||||
match self.config.mcp.mode {
|
||||
McpMode::Legacy => { /* use local */ },
|
||||
McpMode::Enabled => { /* use remote or fallback */ },
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```rust
|
||||
// Always use MCP architecture
|
||||
if let Some(server_cfg) = self.config.mcp_servers.first() {
|
||||
// Try remote server, fallback to local on error
|
||||
} else {
|
||||
// Use local client
|
||||
}
|
||||
```
|
||||
|
||||
## New Features
|
||||
|
||||
### Test Infrastructure
|
||||
|
||||
Added comprehensive mock implementations for testing:
|
||||
|
||||
1. **MockProvider** (`crates/owlen-core/src/provider.rs`)
|
||||
- Located in `provider::test_utils` module
|
||||
- Provides a simple provider for unit tests
|
||||
- Implements all required `Provider` trait methods
|
||||
|
||||
2. **MockMcpClient** (`crates/owlen-core/src/mcp.rs`)
|
||||
- Located in `mcp::test_utils` module
|
||||
- Provides a simple MCP client for unit tests
|
||||
- Returns mock tool descriptors and responses
|
||||
|
||||
### Documentation
|
||||
|
||||
1. **Migration Guide** (`docs/migration-guide.md`)
|
||||
- Comprehensive guide for migrating from v0.x to v1.0
|
||||
- Step-by-step configuration update instructions
|
||||
- Common issues and troubleshooting
|
||||
- Rollback procedures if needed
|
||||
|
||||
2. **Updated Configuration Reference**
|
||||
- Removed references to legacy mode
|
||||
- Clarified MCP server configuration
|
||||
- Added examples for local and cloud Ollama usage
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fixed test compilation errors due to missing mock implementations
|
||||
- Resolved ambiguous glob re-export warnings (non-critical, test-only)
|
||||
|
||||
## Internal Changes
|
||||
|
||||
### Configuration System
|
||||
|
||||
- `McpSettings` struct now only serves as a placeholder for future MCP-specific settings
|
||||
- Removed `McpMode` enum entirely
|
||||
- Default configuration no longer includes mode setting
|
||||
|
||||
### MCP Factory
|
||||
|
||||
- Simplified factory logic by removing mode branching
|
||||
- Improved fallback behavior with better error messages
|
||||
- Test renamed to reflect new behavior: `test_factory_creates_local_client_when_no_servers_configured`
|
||||
|
||||
## Performance
|
||||
|
||||
No performance regressions expected. The MCP architecture may actually improve performance by:
|
||||
- Removing unnecessary mode checks
|
||||
- Streamlining the client creation process
|
||||
- Better error handling reduces retry overhead
|
||||
|
||||
## Compatibility
|
||||
|
||||
### Backwards Compatibility
|
||||
|
||||
**Breaking:** Configuration files with `mode = "legacy"` will need to be updated:
|
||||
- The setting is ignored (logs a warning in future versions)
|
||||
- User config has been automatically updated if using standard path
|
||||
|
||||
### Forward Compatibility
|
||||
|
||||
The `McpSettings` struct is kept for future expansion:
|
||||
- Can add MCP-specific timeouts
|
||||
- Can add connection pooling settings
|
||||
- Can add server selection strategies
|
||||
|
||||
## Testing
|
||||
|
||||
All tests passing:
|
||||
```
|
||||
test result: ok. 29 passed; 0 failed; 0 ignored
|
||||
```
|
||||
|
||||
Key test areas:
|
||||
- Agent ReAct pattern parsing
|
||||
- MCP client factory creation
|
||||
- Configuration loading and validation
|
||||
- Mode-based tool filtering
|
||||
- Permission and consent handling
|
||||
|
||||
## Upgrade Instructions
|
||||
|
||||
See [Migration Guide](migration-guide.md) for detailed instructions.
|
||||
|
||||
**Quick upgrade:**
|
||||
|
||||
1. Update your `~/.config/owlen/config.toml`:
|
||||
```bash
|
||||
# Remove the 'mode' line from [mcp] section
|
||||
sed -i '/^mode = /d' ~/.config/owlen/config.toml
|
||||
```
|
||||
|
||||
2. Rebuild Owlen:
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
3. Test with a simple query:
|
||||
```bash
|
||||
owlen
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
1. **Warning about ambiguous glob re-exports** - Non-critical, only affects test builds
|
||||
2. **First inference may be slow** - Ollama loads models on first use (expected behavior)
|
||||
3. **Cloud model 404 errors** - Ensure model names match Ollama Cloud's naming (remove `-cloud` suffix from model names)
|
||||
|
||||
## Contributors
|
||||
|
||||
This release completes the Phase 10 migration plan documented in `.agents/new_phases.md`.
|
||||
|
||||
## Related Issues
|
||||
|
||||
- Closes: Legacy mode removal
|
||||
- Implements: Phase 10 cleanup and production polish
|
||||
- References: MCP architecture migration phases 1-10
|
||||
@@ -31,10 +31,54 @@ A simplified diagram of how components interact:
|
||||
|
||||
## Crate Breakdown
|
||||
|
||||
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`.
|
||||
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`. Also contains the MCP client implementation.
|
||||
- `owlen-tui`: Contains all the logic for the terminal user interface, including event handling and rendering.
|
||||
- `owlen-cli`: The command-line entry point, responsible for parsing arguments and starting the TUI.
|
||||
- `owlen-ollama` / `owlen-openai` / etc.: Implementations of the `Provider` trait for specific services.
|
||||
- `owlen-mcp-llm-server`: MCP server that wraps Ollama providers and exposes them via the Model Context Protocol.
|
||||
- `owlen-mcp-server`: Generic MCP server for file operations and resource management.
|
||||
- `owlen-ollama`: Direct Ollama provider implementation (legacy, used only by MCP servers).
|
||||
|
||||
## MCP Architecture (Phase 10)
|
||||
|
||||
As of Phase 10, OWLEN uses a **MCP-only architecture** where all LLM interactions go through the Model Context Protocol:
|
||||
|
||||
```
|
||||
[TUI/CLI] -> [RemoteMcpClient] -> [MCP LLM Server] -> [Ollama Provider] -> [Ollama API]
|
||||
```
|
||||
|
||||
### Benefits of MCP Architecture
|
||||
|
||||
1. **Separation of Concerns**: The TUI/CLI never directly instantiates provider implementations.
|
||||
2. **Process Isolation**: LLM interactions run in a separate process, improving stability.
|
||||
3. **Extensibility**: New providers can be added by implementing MCP servers.
|
||||
4. **Multi-Transport**: Supports STDIO, HTTP, and WebSocket transports.
|
||||
5. **Tool Integration**: MCP servers can expose tools (file operations, web search, etc.) to the LLM.
|
||||
|
||||
### MCP Communication Flow
|
||||
|
||||
1. **Client Creation**: `RemoteMcpClient::new()` spawns an MCP server binary via STDIO.
|
||||
2. **Initialization**: Client sends `initialize` request to establish protocol version.
|
||||
3. **Tool Discovery**: Client calls `tools/list` to discover available LLM operations.
|
||||
4. **Chat Requests**: Client calls the `generate_text` tool with chat parameters.
|
||||
5. **Streaming**: Server sends progress notifications during generation, then final response.
|
||||
6. **Response Handling**: Client skips notifications and returns the final text to the caller.
|
||||
|
||||
### Cloud Provider Support
|
||||
|
||||
For Ollama Cloud providers, the MCP server accepts an `OLLAMA_URL` environment variable:
|
||||
|
||||
```rust
|
||||
let env_vars = HashMap::from([
|
||||
("OLLAMA_URL".to_string(), "https://cloud-provider-url".to_string())
|
||||
]);
|
||||
let config = McpServerConfig {
|
||||
command: "path/to/owlen-mcp-llm-server",
|
||||
env: env_vars,
|
||||
transport: "stdio",
|
||||
...
|
||||
};
|
||||
let client = RemoteMcpClient::new_with_config(&config)?;
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
|
||||
@@ -6,29 +6,183 @@ As Owlen is currently in its alpha phase (pre-v1.0), breaking changes may occur
|
||||
|
||||
---
|
||||
|
||||
## Migrating from v0.1.x to v0.2.x (Example)
|
||||
## Migrating from v0.x to v1.0 (MCP-Only Architecture)
|
||||
|
||||
*This is a template for a future migration. No breaking changes have occurred yet.*
|
||||
**Version 1.0** marks a major milestone: Owlen has completed its transition to a **MCP-only architecture** (Model Context Protocol). This brings significant improvements in modularity, extensibility, and performance, but requires configuration updates.
|
||||
|
||||
Version 0.2.0 introduces a new configuration structure for providers.
|
||||
### Breaking Changes
|
||||
|
||||
### Configuration File Changes
|
||||
#### 1. MCP Mode is Now Always Enabled
|
||||
|
||||
Previously, your `config.toml` might have looked like this:
|
||||
The `[mcp]` section in `config.toml` previously had a `mode` setting that could be set to `"legacy"` or `"enabled"`. In v1.0+, MCP architecture is **always enabled** and the `mode` setting has been removed.
|
||||
|
||||
**Old configuration (v0.x):**
|
||||
```toml
|
||||
# old config.toml (pre-v0.2.0)
|
||||
ollama_base_url = "http://localhost:11434"
|
||||
[mcp]
|
||||
mode = "legacy" # or "enabled"
|
||||
```
|
||||
|
||||
In v0.2.0, all provider settings are now nested under a `[providers]` table. You will need to update your `config.toml` to the new format:
|
||||
**New configuration (v1.0+):**
|
||||
```toml
|
||||
[mcp]
|
||||
# MCP is now always enabled - no mode setting needed
|
||||
# This section is kept for future MCP-specific configuration options
|
||||
```
|
||||
|
||||
#### 2. Direct Provider Access Removed
|
||||
|
||||
In v0.x, Owlen could make direct HTTP calls to Ollama and other providers when in "legacy" mode. In v1.0+, **all LLM interactions go through MCP servers**.
|
||||
|
||||
### What Changed Under the Hood
|
||||
|
||||
The v1.0 architecture implements the full 10-phase migration plan:
|
||||
|
||||
- **Phase 1-2**: File operations via MCP servers
|
||||
- **Phase 3**: LLM inference via MCP servers (Ollama wrapped)
|
||||
- **Phase 4**: Agent loop with ReAct pattern
|
||||
- **Phase 5**: Mode system (chat/code) with tool availability
|
||||
- **Phase 6**: Web search integration
|
||||
- **Phase 7**: Code execution with Docker sandboxing
|
||||
- **Phase 8**: Prompt server for versioned prompts
|
||||
- **Phase 9**: Remote MCP server support (HTTP/WebSocket)
|
||||
- **Phase 10**: Legacy mode removal and production polish
|
||||
|
||||
### Migration Steps
|
||||
|
||||
#### Step 1: Update Your Configuration
|
||||
|
||||
Edit `~/.config/owlen/config.toml`:
|
||||
|
||||
**Remove the `mode` line:**
|
||||
```diff
|
||||
[mcp]
|
||||
-mode = "legacy"
|
||||
```
|
||||
|
||||
The `[mcp]` section can now be empty or contain future MCP-specific settings.
|
||||
|
||||
#### Step 2: Verify Provider Configuration
|
||||
|
||||
Ensure your provider configuration is correct. For Ollama:
|
||||
|
||||
```toml
|
||||
# new config.toml (v0.2.0+)
|
||||
[general]
|
||||
default_provider = "ollama"
|
||||
default_model = "llama3.2:latest" # or your preferred model
|
||||
|
||||
[providers.ollama]
|
||||
provider_type = "ollama"
|
||||
base_url = "http://localhost:11434"
|
||||
|
||||
[providers.ollama-cloud]
|
||||
provider_type = "ollama-cloud"
|
||||
base_url = "https://ollama.com"
|
||||
api_key = "$OLLAMA_API_KEY" # Optional: for Ollama Cloud
|
||||
```
|
||||
|
||||
### Action Required
|
||||
#### Step 3: Understanding MCP Server Configuration
|
||||
|
||||
Update your `~/.config/owlen/config.toml` to match the new structure. If you do not, Owlen will fall back to its default provider configuration.
|
||||
While not required for basic usage (Owlen will use the built-in local MCP client), you can optionally configure external MCP servers:
|
||||
|
||||
```toml
|
||||
[[mcp_servers]]
|
||||
name = "llm"
|
||||
command = "owlen-mcp-llm-server"
|
||||
transport = "stdio"
|
||||
|
||||
[[mcp_servers]]
|
||||
name = "filesystem"
|
||||
command = "/path/to/filesystem-server"
|
||||
transport = "stdio"
|
||||
```
|
||||
|
||||
**Note**: If no `mcp_servers` are configured, Owlen automatically falls back to its built-in local MCP client, which provides the same functionality.
|
||||
|
||||
#### Step 4: Verify Installation
|
||||
|
||||
After updating your config:
|
||||
|
||||
1. **Check Ollama is running**:
|
||||
```bash
|
||||
curl http://localhost:11434/api/version
|
||||
```
|
||||
|
||||
2. **List available models**:
|
||||
```bash
|
||||
ollama list
|
||||
```
|
||||
|
||||
3. **Test Owlen**:
|
||||
```bash
|
||||
owlen
|
||||
```
|
||||
|
||||
### Common Issues After Migration
|
||||
|
||||
#### Issue: "Warning: No MCP servers defined in config. Using local client."
|
||||
|
||||
**This is normal!** In v1.0+, if you don't configure external MCP servers, Owlen uses its built-in local MCP client. This provides the same functionality without needing separate server processes.
|
||||
|
||||
**No action required** unless you specifically want to use external MCP servers.
|
||||
|
||||
#### Issue: Timeouts on First Message
|
||||
|
||||
**Cause**: Ollama loads models into memory on first use, which can take 10-60 seconds for large models.
|
||||
|
||||
**Solution**:
|
||||
- Be patient on first inference after model selection
|
||||
- Use smaller models for faster loading (e.g., `llama3.2:latest` instead of `qwen3-coder:latest`)
|
||||
- Pre-load models with: `ollama run <model-name>`
|
||||
|
||||
#### Issue: Cloud Models Return 404 Errors
|
||||
|
||||
**Cause**: Ollama Cloud model names may differ from local model names.
|
||||
|
||||
**Solution**:
|
||||
- Verify model availability on https://ollama.com/models
|
||||
- Remove the `-cloud` suffix from model names when using cloud provider
|
||||
- Ensure `api_key` is set in `[providers.ollama-cloud]` config
|
||||
|
||||
### Rollback to v0.x
|
||||
|
||||
If you encounter issues and need to rollback:
|
||||
|
||||
1. **Reinstall v0.x**:
|
||||
```bash
|
||||
# Using AUR (if applicable)
|
||||
yay -S owlen-git
|
||||
|
||||
# Or from source
|
||||
git checkout <v0.x-tag>
|
||||
cargo install --path crates/owlen-tui
|
||||
```
|
||||
|
||||
2. **Restore configuration**:
|
||||
```toml
|
||||
[mcp]
|
||||
mode = "legacy"
|
||||
```
|
||||
|
||||
3. **Report issues**: https://github.com/Owlibou/owlen/issues
|
||||
|
||||
### Benefits of v1.0 MCP Architecture
|
||||
|
||||
- **Modularity**: LLM, file operations, and tools are isolated in MCP servers
|
||||
- **Extensibility**: Easy to add new tools and capabilities via MCP protocol
|
||||
- **Multi-Provider**: Support for multiple LLM providers through standard interface
|
||||
- **Remote Execution**: Can connect to remote MCP servers over HTTP/WebSocket
|
||||
- **Better Error Handling**: Structured error responses from MCP servers
|
||||
- **Agentic Capabilities**: ReAct pattern for autonomous task completion
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Documentation**: See `docs/` directory for detailed guides
|
||||
- **Issues**: https://github.com/Owlibou/owlen/issues
|
||||
- **Configuration Reference**: `docs/configuration.md`
|
||||
- **Troubleshooting**: `docs/troubleshooting.md`
|
||||
|
||||
---
|
||||
|
||||
## Future Migrations
|
||||
|
||||
We will continue to document breaking changes here as Owlen evolves. Always check this guide when upgrading to a new major version.
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// This example demonstrates a basic chat interaction without the TUI.
|
||||
|
||||
use owlen_core::model::Model;
|
||||
use owlen_core::provider::Provider;
|
||||
use owlen_core::session::Session;
|
||||
use owlen_ollama::OllamaProvider; // Assuming you have an Ollama provider
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
// This example requires a running Ollama instance.
|
||||
// Make sure you have a model available, e.g., `ollama pull llama2`
|
||||
|
||||
let provider = OllamaProvider;
|
||||
let model = Model::new("llama2"); // Change to a model you have
|
||||
let mut session = Session::new("basic-chat-session");
|
||||
|
||||
println!("Starting basic chat with model: {}", model.name);
|
||||
|
||||
let user_message = "What is the capital of France?";
|
||||
session.add_message("user", user_message);
|
||||
println!("User: {}", user_message);
|
||||
|
||||
// Send the chat to the provider
|
||||
let response = provider.chat(&session, &model).await?;
|
||||
|
||||
session.add_message("bot", &response);
|
||||
println!("Bot: {}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// This example demonstrates how to implement a custom provider.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use owlen_core::model::Model;
|
||||
use owlen_core::provider::Provider;
|
||||
use owlen_core::session::Session;
|
||||
|
||||
// Define a struct for your custom provider.
|
||||
pub struct MyCustomProvider;
|
||||
|
||||
// Implement the `Provider` trait for your struct.
|
||||
#[async_trait]
|
||||
impl Provider for MyCustomProvider {
|
||||
fn name(&self) -> &str {
|
||||
"custom-provider"
|
||||
}
|
||||
|
||||
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
|
||||
println!(
|
||||
"Custom provider received chat request for model: {}",
|
||||
model.name
|
||||
);
|
||||
// In a real implementation, you would send the session data to an API.
|
||||
let message_count = session.get_messages().len();
|
||||
Ok(format!(
|
||||
"This is a custom response. You have {} messages in your session.",
|
||||
message_count
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
let provider = MyCustomProvider;
|
||||
let model = Model::new("custom-model");
|
||||
let mut session = Session::new("custom-session");
|
||||
|
||||
session.add_message("user", "Hello, custom provider!");
|
||||
|
||||
let response = provider.chat(&session, &model).await?;
|
||||
|
||||
println!("Provider response: {}", response);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
71
examples/mcp_chat.rs
Normal file
71
examples/mcp_chat.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Example demonstrating MCP-based chat interaction.
|
||||
//!
|
||||
//! This example shows the recommended way to interact with LLMs via the MCP architecture.
|
||||
//! It uses `RemoteMcpClient` which communicates with the MCP LLM server.
|
||||
//!
|
||||
//! Prerequisites:
|
||||
//! - Build the MCP LLM server: `cargo build --release -p owlen-mcp-llm-server`
|
||||
//! - Ensure Ollama is running with a model available
|
||||
|
||||
use owlen_core::{
|
||||
mcp::remote_client::RemoteMcpClient,
|
||||
types::{ChatParameters, ChatRequest, Message, Role},
|
||||
Provider,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
println!("🦉 Owlen MCP Chat Example\n");
|
||||
|
||||
// Create MCP client - this will spawn/connect to the MCP LLM server
|
||||
println!("Connecting to MCP LLM server...");
|
||||
let client = Arc::new(RemoteMcpClient::new()?);
|
||||
println!("✓ Connected\n");
|
||||
|
||||
// List available models
|
||||
println!("Fetching available models...");
|
||||
let models = client.list_models().await?;
|
||||
println!("Available models:");
|
||||
for model in &models {
|
||||
println!(" - {} ({})", model.name, model.provider);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Select first available model or default
|
||||
let model_name = models
|
||||
.first()
|
||||
.map(|m| m.id.clone())
|
||||
.unwrap_or_else(|| "llama3.2:latest".to_string());
|
||||
println!("Using model: {}\n", model_name);
|
||||
|
||||
// Create a simple chat request
|
||||
let user_message = "What is the capital of France? Please be concise.";
|
||||
println!("User: {}", user_message);
|
||||
|
||||
let request = ChatRequest {
|
||||
model: model_name,
|
||||
messages: vec![Message::new(Role::User, user_message.to_string())],
|
||||
parameters: ChatParameters {
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(100),
|
||||
stream: false,
|
||||
extra: std::collections::HashMap::new(),
|
||||
},
|
||||
tools: None,
|
||||
};
|
||||
|
||||
// Send request and get response
|
||||
println!("\nAssistant: ");
|
||||
let response = client.chat(request).await?;
|
||||
println!("{}", response.message.content);
|
||||
|
||||
if let Some(usage) = response.usage {
|
||||
println!(
|
||||
"\n📊 Tokens: {} prompt + {} completion = {} total",
|
||||
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user