diff --git a/crates/owlen-core/src/mcp/remote_client.rs b/crates/owlen-core/src/mcp/remote_client.rs index 5f5475a..7840c16 100644 --- a/crates/owlen-core/src/mcp/remote_client.rs +++ b/crates/owlen-core/src/mcp/remote_client.rs @@ -1,5 +1,7 @@ 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}; @@ -148,11 +150,12 @@ impl RemoteMcpClient { .join("../..") .canonicalize() .map_err(Error::Io)?; - // Prefer the generic file‑server binary over the LLM server, as the tests - // exercise the resource tools (read/write/delete). + // 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-server", "target/debug/owlen-mcp-llm-server", + "target/release/owlen-mcp-llm-server", + "target/debug/owlen-mcp-server", ]; let binary_path = candidates .iter() @@ -160,8 +163,8 @@ impl RemoteMcpClient { .find(|p| p.exists()) .ok_or_else(|| { Error::NotImplemented(format!( - "owlen-mcp server binary not found; checked {} and {}", - candidates[0], candidates[1] + "owlen-mcp server binary not found; checked {}, {}, and {}", + candidates[0], candidates[1], candidates[2] )) })?; let config = crate::config::McpServerConfig { @@ -263,29 +266,48 @@ impl RemoteMcpClient { } // STDIO path (default). - let mut line = String::new(); - { - 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 - if let Ok(resp) = serde_json::from_str::(&line) { - if resp.id == id { - return Ok(resp.result); + // Loop to skip notifications and find the response with matching ID. + loop { + let mut line = String::new(); + { + 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 as notification first (has no id field) + if let Ok(_notif) = serde_json::from_str::(&line) { + // Skip notifications and continue reading + continue; + } + + // Try to parse successful response + if let Ok(resp) = serde_json::from_str::(&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 + if let Ok(err_resp) = serde_json::from_str::(&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() + ))); } - // Fallback to error response - let err_resp: RpcErrorResponse = - serde_json::from_str(&line).map_err(Error::Serialization)?; - Err(Error::Network(format!( - "MCP server error {}: {}", - err_resp.error.code, err_resp.error.message - ))) } } @@ -436,14 +458,9 @@ impl McpClient for RemoteMcpClient { // 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) } } diff --git a/crates/owlen-mcp-llm-server/src/lib.rs b/crates/owlen-mcp-llm-server/src/lib.rs index c3ca52d..8c38419 100644 --- a/crates/owlen-mcp-llm-server/src/lib.rs +++ b/crates/owlen-mcp-llm-server/src/lib.rs @@ -107,8 +107,10 @@ fn resources_list_descriptor() -> McpToolDescriptor { } async fn handle_generate_text(args: GenerateTextArgs) -> Result { - // 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 { // 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( diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index 4608207..ae9367e 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -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 } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 74dc0b7..b2c22c5 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -2320,14 +2320,14 @@ impl ChatApp { } async fn collect_models_from_all_providers(&self) -> (Vec, Vec) { - 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,36 +2339,64 @@ impl ChatApp { continue; } - // Separate handling based on provider type. - if provider_type == "ollama" { - // Local Ollama – communicate via the MCP LLM server. - match RemoteMcpClient::new() { - Ok(client) => match client.list_models().await { - Ok(mut provider_models) => { - for model in &mut provider_models { - model.provider = name.clone(); - } - models.extend(provider_models); - } - Err(err) => errors.push(format!("{}: {}", name, err)), - }, - Err(err) => errors.push(format!("{}: {}", name, err)), + // 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 { - // 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); + // 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 { + model.provider = name.clone(); } - Err(err) => errors.push(format!("{}: {}", name, err)), - }, + models.extend(provider_models); + } Err(err) => errors.push(format!("{}: {}", name, err)), - } + }, + Err(err) => errors.push(format!("{}: {}", name, err)), } } @@ -2602,18 +2630,46 @@ impl ChatApp { cfg.clone() }; - let general = self.controller.config().general.clone(); - // Choose the appropriate provider implementation based on its type. - let provider: Arc = - if provider_cfg.provider_type.eq_ignore_ascii_case("ollama") { - // Local Ollama via MCP server. - Arc::new(RemoteMcpClient::new()?) - } else { - // Ollama Cloud – instantiate the direct provider. - use owlen_ollama::OllamaProvider; - let ollama = OllamaProvider::from_config(&provider_cfg, Some(&general))?; - Arc::new(ollama) + // All providers use MCP architecture (Phase 10). + // For cloud providers, pass the URL via environment variable. + let provider: Arc = 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 { + // Local Ollama via default MCP client + Arc::new(RemoteMcpClient::new()?) + }; self.controller.switch_provider(provider).await?; self.current_provider = provider_name.to_string(); diff --git a/docs/architecture.md b/docs/architecture.md index a1d1809..ae901a4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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