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:
@@ -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 file‑server 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user