feat(phase10): complete MCP-only architecture migration

Phase 10 "Cleanup & Production Polish" is now complete. All LLM
interactions now go through the Model Context Protocol (MCP), removing
direct provider dependencies from CLI/TUI.

## Major Changes

### MCP Architecture
- All providers (local and cloud Ollama) now use RemoteMcpClient
- Removed owlen-ollama dependency from owlen-tui
- MCP LLM server accepts OLLAMA_URL environment variable for cloud providers
- Proper notification handling for streaming responses
- Fixed response deserialization (McpToolResponse unwrapping)

### Code Cleanup
- Removed direct OllamaProvider instantiation from TUI
- Updated collect_models_from_all_providers() to use MCP for all providers
- Updated switch_provider() to use MCP with environment configuration
- Removed unused general config variable

### Documentation
- Added comprehensive MCP Architecture section to docs/architecture.md
- Documented MCP communication flow and cloud provider support
- Updated crate breakdown to reflect MCP servers

### Security & Performance
- Path traversal protection verified for all resource operations
- Process isolation via separate MCP server processes
- Tool permissions controlled via consent manager
- Clean release build of entire workspace verified

## Benefits of MCP Architecture

1. **Separation of Concerns**: TUI/CLI never directly instantiates providers
2. **Process Isolation**: LLM interactions run in separate processes
3. **Extensibility**: New providers can be added as MCP servers
4. **Multi-Transport**: Supports STDIO, HTTP, and WebSocket
5. **Tool Integration**: MCP servers expose tools to LLMs

This completes Phase 10 and establishes a clean, production-ready architecture
for future development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-10 23:34:05 +02:00
parent 9545a4b3ad
commit 7534c9ef8d
5 changed files with 204 additions and 82 deletions

View File

@@ -1,5 +1,7 @@
use super::protocol::methods; 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 super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::consent::{ConsentManager, ConsentScope}; use crate::consent::{ConsentManager, ConsentScope};
use crate::tools::{Tool, WebScrapeTool, WebSearchTool}; use crate::tools::{Tool, WebScrapeTool, WebSearchTool};
@@ -148,11 +150,12 @@ impl RemoteMcpClient {
.join("../..") .join("../..")
.canonicalize() .canonicalize()
.map_err(Error::Io)?; .map_err(Error::Io)?;
// Prefer the generic fileserver binary over the LLM server, as the tests // Prefer the LLM server binary as it provides both LLM and resource tools.
// exercise the resource tools (read/write/delete). // The generic file-server is kept as a fallback for testing.
let candidates = [ let candidates = [
"target/debug/owlen-mcp-server",
"target/debug/owlen-mcp-llm-server", "target/debug/owlen-mcp-llm-server",
"target/release/owlen-mcp-llm-server",
"target/debug/owlen-mcp-server",
]; ];
let binary_path = candidates let binary_path = candidates
.iter() .iter()
@@ -160,8 +163,8 @@ impl RemoteMcpClient {
.find(|p| p.exists()) .find(|p| p.exists())
.ok_or_else(|| { .ok_or_else(|| {
Error::NotImplemented(format!( Error::NotImplemented(format!(
"owlen-mcp server binary not found; checked {} and {}", "owlen-mcp server binary not found; checked {}, {}, and {}",
candidates[0], candidates[1] candidates[0], candidates[1], candidates[2]
)) ))
})?; })?;
let config = crate::config::McpServerConfig { let config = crate::config::McpServerConfig {
@@ -263,29 +266,48 @@ impl RemoteMcpClient {
} }
// STDIO path (default). // STDIO path (default).
let mut line = String::new(); // Loop to skip notifications and find the response with matching ID.
{ loop {
let mut stdout = self let mut line = String::new();
.stdout {
.as_ref() let mut stdout = self
.ok_or_else(|| Error::Network("STDIO stdout not available".into()))? .stdout
.lock() .as_ref()
.await; .ok_or_else(|| Error::Network("STDIO stdout not available".into()))?
stdout.read_line(&mut line).await?; .lock()
} .await;
// Try to parse successful response first stdout.read_line(&mut line).await?;
if let Ok(resp) = serde_json::from_str::<RpcResponse>(&line) {
if resp.id == id {
return Ok(resp.result);
} }
// 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
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()
)));
} }
// 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. // specific tool name and its arguments. Wrap the incoming call accordingly.
let payload = serde_json::to_value(&call)?; let payload = serde_json::to_value(&call)?;
let result = self.send_rpc(methods::TOOLS_CALL, payload).await?; let result = self.send_rpc(methods::TOOLS_CALL, payload).await?;
// The server returns the tool's output directly; construct a matching response. // The server returns an McpToolResponse; deserialize it.
Ok(McpToolResponse { let response: McpToolResponse = serde_json::from_value(result)?;
name: call.name, Ok(response)
success: true,
output: result,
metadata: std::collections::HashMap::new(),
duration_ms: 0,
})
} }
} }

View File

@@ -107,8 +107,10 @@ fn resources_list_descriptor() -> McpToolDescriptor {
} }
async fn handle_generate_text(args: GenerateTextArgs) -> Result<String, RpcError> { async fn handle_generate_text(args: GenerateTextArgs) -> Result<String, RpcError> {
// Create provider with default local Ollama URL // Create provider with Ollama URL from environment or default to localhost
let provider = OllamaProvider::new("http://localhost:11434") 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)))?; .map_err(|e| RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e)))?;
let parameters = ChatParameters { 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. // New method to list available Ollama models via the provider.
methods::MODELS_LIST => { methods::MODELS_LIST => {
// Reuse the provider instance for model listing. // 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)) RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e))
})?; })?;
let models = provider let models = provider
@@ -377,7 +381,9 @@ async fn main() -> anyhow::Result<()> {
}; };
// Initialize Ollama provider and start streaming // 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, Ok(p) => p,
Err(e) => { Err(e) => {
let err_resp = RpcErrorResponse::new( let err_resp = RpcErrorResponse::new(

View File

@@ -10,8 +10,7 @@ description = "Terminal User Interface for OWLEN LLM client"
[dependencies] [dependencies]
owlen-core = { path = "../owlen-core" } owlen-core = { path = "../owlen-core" }
owlen-ollama = { path = "../owlen-ollama" } # Removed owlen-ollama dependency - all providers now accessed via MCP architecture (Phase 10)
# Removed circular dependency on `owlen-cli`. The TUI no longer directly depends on the CLI crate.
# TUI framework # TUI framework
ratatui = { workspace = true } ratatui = { workspace = true }

View File

@@ -2320,14 +2320,14 @@ impl ChatApp {
} }
async fn collect_models_from_all_providers(&self) -> (Vec<ModelInfo>, Vec<String>) { 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 config = self.controller.config();
let entries: Vec<(String, ProviderConfig)> = config let entries: Vec<(String, ProviderConfig)> = config
.providers .providers
.iter() .iter()
.map(|(name, cfg)| (name.clone(), cfg.clone())) .map(|(name, cfg)| (name.clone(), cfg.clone()))
.collect(); .collect();
(entries, config.general.clone()) entries
}; };
let mut models = Vec::new(); let mut models = Vec::new();
@@ -2339,36 +2339,64 @@ impl ChatApp {
continue; continue;
} }
// Separate handling based on provider type. // All providers communicate via MCP LLM server (Phase 10).
if provider_type == "ollama" { // For cloud providers, the URL is passed via the provider config.
// Local Ollama communicate via the MCP LLM server. let client_result = if provider_type == "ollama-cloud" {
match RemoteMcpClient::new() { // Cloud Ollama - create MCP client with custom URL via env var
Ok(client) => match client.list_models().await { use owlen_core::config::McpServerConfig;
Ok(mut provider_models) => { use std::collections::HashMap;
for model in &mut provider_models {
model.provider = name.clone(); let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
} .join("../..")
models.extend(provider_models); .canonicalize()
} .ok();
Err(err) => errors.push(format!("{}: {}", name, err)),
}, let binary_path = workspace_root.and_then(|root| {
Err(err) => errors.push(format!("{}: {}", name, err)), 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 { } else {
// Ollama Cloud use the direct Ollama provider implementation. // Local Ollama - use default MCP client
use owlen_ollama::OllamaProvider; RemoteMcpClient::new()
match OllamaProvider::from_config(&provider_cfg, Some(&general)) { };
Ok(provider) => match provider.list_models().await {
Ok(mut cloud_models) => { match client_result {
for model in &mut cloud_models { Ok(client) => match client.list_models().await {
model.provider = name.clone(); Ok(mut provider_models) => {
} for model in &mut provider_models {
models.extend(cloud_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)),
} },
Err(err) => errors.push(format!("{}: {}", name, err)),
} }
} }
@@ -2602,18 +2630,46 @@ impl ChatApp {
cfg.clone() cfg.clone()
}; };
let general = self.controller.config().general.clone(); // All providers use MCP architecture (Phase 10).
// Choose the appropriate provider implementation based on its type. // For cloud providers, pass the URL via environment variable.
let provider: Arc<dyn owlen_core::provider::Provider> = let provider: Arc<dyn owlen_core::provider::Provider> = if provider_cfg
if provider_cfg.provider_type.eq_ignore_ascii_case("ollama") { .provider_type
// Local Ollama via MCP server. .eq_ignore_ascii_case("ollama-cloud")
Arc::new(RemoteMcpClient::new()?) {
} else { // Cloud Ollama - create MCP client with custom URL
// Ollama Cloud instantiate the direct provider. use owlen_core::config::McpServerConfig;
use owlen_ollama::OllamaProvider; use std::collections::HashMap;
let ollama = OllamaProvider::from_config(&provider_cfg, Some(&general))?;
Arc::new(ollama) 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.controller.switch_provider(provider).await?;
self.current_provider = provider_name.to_string(); self.current_provider = provider_name.to_string();

View File

@@ -31,10 +31,54 @@ A simplified diagram of how components interact:
## Crate Breakdown ## 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-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-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 ## Session Management