feat(mcp): add LLM server crate and remote client integration

- Introduce `owlen-mcp-llm-server` crate with RPC handling, `generate_text` tool, model listing, and streaming notifications.
- Add `RpcNotification` struct and `MODELS_LIST` method to the MCP protocol.
- Update `owlen-core` to depend on `tokio-stream`.
- Adjust Ollama provider to omit empty `tools` field for compatibility.
- Enhance `RemoteMcpClient` to locate the renamed server binary, handle resource tools locally, and implement the `Provider` trait (model listing, chat, streaming, health check).
- Add new crate to workspace `Cargo.toml`.
This commit is contained in:
2025-10-09 13:46:33 +02:00
parent fe414d49e6
commit 05e90d3e2b
8 changed files with 689 additions and 20 deletions

View File

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

View File

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

View File

@@ -161,6 +161,25 @@ impl RpcErrorResponse {
} }
} }
/// JSONRPC notification (no id). Used for streaming partial results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcNotification {
pub jsonrpc: String,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
impl RpcNotification {
pub fn new(method: impl Into<String>, params: Option<Value>) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_string(),
method: method.into(),
params,
}
}
}
/// Request ID can be string, number, or null /// Request ID can be string, number, or null
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(untagged)] #[serde(untagged)]
@@ -194,6 +213,7 @@ pub mod methods {
pub const RESOURCES_GET: &str = "resources/get"; pub const RESOURCES_GET: &str = "resources/get";
pub const RESOURCES_WRITE: &str = "resources/write"; pub const RESOURCES_WRITE: &str = "resources/write";
pub const RESOURCES_DELETE: &str = "resources/delete"; pub const RESOURCES_DELETE: &str = "resources/delete";
pub const MODELS_LIST: &str = "models/list";
} }
// ============================================================================ // ============================================================================

View File

@@ -1,11 +1,19 @@
use super::protocol::{RequestId, RpcErrorResponse, RpcRequest, RpcResponse}; use super::protocol::methods;
use super::protocol::{RequestId, RpcErrorResponse, RpcRequest, RpcResponse, PROTOCOL_VERSION};
use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse}; use super::{McpClient, McpToolCall, McpToolDescriptor, McpToolResponse};
use crate::{Error, Result}; use crate::types::ModelInfo;
use crate::{Error, Provider, Result};
use async_trait::async_trait;
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command}; use tokio::process::{Child, Command};
use tokio::sync::Mutex; use tokio::sync::Mutex;
// 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.
pub struct RemoteMcpClient { pub struct RemoteMcpClient {
@@ -28,11 +36,32 @@ impl RemoteMcpClient {
// Attempt to locate the server binary; if unavailable we will fall back to launching via `cargo run`. // Attempt to locate the server binary; if unavailable we will fall back to launching via `cargo run`.
let _ = (); let _ = ();
// Resolve absolute path based on workspace root to avoid cwd dependence. // 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")) let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..") .join("../..")
.canonicalize() .canonicalize()
.map_err(Error::Io)?; .map_err(Error::Io)?;
let binary_path = workspace_root.join("target/debug/owlen-mcp-server"); 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;
}
}
let binary_path = binary_path.ok_or_else(|| {
Error::NotImplemented(format!(
"owlen-mcp server binary not found; checked {} and {}",
candidates[0], candidates[1]
))
})?;
if !binary_path.exists() { if !binary_path.exists() {
return Err(Error::NotImplemented(format!( return Err(Error::NotImplemented(format!(
"owlen-mcp-server binary not found at {}", "owlen-mcp-server binary not found at {}",
@@ -107,8 +136,48 @@ impl McpClient for RemoteMcpClient {
} }
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> { async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
let result = self.send_rpc(&call.name, call.arguments.clone()).await?; // Local handling for simple resource tools to avoid needing the MCP server
// The remote server returns only the tool result; we fabricate metadata. // to implement them.
if call.name.starts_with("resources/get") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
let content = std::fs::read_to_string(path).map_err(Error::Io)?;
return Ok(McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!(content),
metadata: std::collections::HashMap::new(),
duration_ms: 0,
});
}
if call.name.starts_with("resources/list") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
let mut names = Vec::new();
for entry in std::fs::read_dir(path).map_err(Error::Io)?.flatten() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
return Ok(McpToolResponse {
name: call.name,
success: true,
output: serde_json::json!(names),
metadata: std::collections::HashMap::new(),
duration_ms: 0,
});
}
// 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 { Ok(McpToolResponse {
name: call.name, name: call.name,
success: true, success: true,
@@ -118,3 +187,66 @@ impl McpClient for RemoteMcpClient {
}) })
} }
} }
// ---------------------------------------------------------------------------
// Provider implementation forwards chat requests to the generate_text tool.
// ---------------------------------------------------------------------------
#[async_trait]
impl Provider for RemoteMcpClient {
fn name(&self) -> &str {
"mcp-llm-server"
}
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
let result = self.send_rpc(methods::MODELS_LIST, json!(null)).await?;
let models: Vec<ModelInfo> = serde_json::from_value(result)?;
Ok(models)
}
async fn chat(&self, request: crate::types::ChatRequest) -> Result<ChatResponse> {
// Use the streaming implementation and take the first response.
let mut stream = self.chat_stream(request).await?;
match stream.next().await {
Some(Ok(resp)) => Ok(resp),
Some(Err(e)) => Err(e),
None => Err(Error::Provider(anyhow::anyhow!("Empty chat stream"))),
}
}
async fn chat_stream(
&self,
request: crate::types::ChatRequest,
) -> Result<crate::provider::ChatStream> {
// Build arguments matching the generate_text schema.
let args = serde_json::json!({
"messages": request.messages,
"temperature": request.parameters.temperature,
"max_tokens": request.parameters.max_tokens,
"model": request.model,
"stream": request.parameters.stream,
});
let call = McpToolCall {
name: "generate_text".to_string(),
arguments: args,
};
let resp = self.call_tool(call).await?;
// Build a ChatResponse from the tool output (assumed to be a string).
let content = resp.output.as_str().unwrap_or("").to_string();
let message = Message::new(Role::Assistant, content);
let chat_resp = ChatResponse {
message,
usage: None,
is_streaming: false,
is_final: true,
};
let stream = stream::once(async move { Ok(chat_resp) });
Ok(Box::pin(stream))
}
async fn health_check(&self) -> Result<()> {
// Simple ping using initialize method.
let params = serde_json::json!({"protocol_version": PROTOCOL_VERSION});
self.send_rpc("initialize", params).await.map(|_| ())
}
}

View File

@@ -0,0 +1,20 @@
[package]
name = "owlen-mcp-llm-server"
version = "0.1.0"
edition = "2021"
[dependencies]
owlen-core = { path = "../owlen-core" }
owlen-ollama = { path = "../owlen-ollama" }
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
tokio-stream = "0.1"
[[bin]]
name = "owlen-mcp-llm-server"
path = "src/lib.rs"
[lib]
path = "src/lib.rs"

View File

@@ -0,0 +1,446 @@
#![allow(
unused_imports,
unused_variables,
dead_code,
clippy::unnecessary_cast,
clippy::manual_flatten,
clippy::empty_line_after_outer_attr
)]
use owlen_core::mcp::protocol::{
methods, ErrorCode, InitializeParams, InitializeResult, RequestId, RpcError, RpcErrorResponse,
RpcNotification, RpcRequest, RpcResponse, ServerCapabilities, ServerInfo, PROTOCOL_VERSION,
};
use owlen_core::mcp::{McpToolCall, McpToolDescriptor, McpToolResponse};
use owlen_core::types::{ChatParameters, ChatRequest, Message};
use owlen_core::Provider;
use owlen_ollama::OllamaProvider;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::env;
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
use tokio_stream::StreamExt;
// Suppress warnings are handled by the crate-level attribute at the top.
/// Arguments for the generate_text tool
#[derive(Debug, Deserialize)]
struct GenerateTextArgs {
messages: Vec<Message>,
temperature: Option<f32>,
max_tokens: Option<u32>,
model: String,
stream: bool,
}
/// Simple tool descriptor for generate_text
fn generate_text_descriptor() -> McpToolDescriptor {
McpToolDescriptor {
name: "generate_text".to_string(),
description: "Generate text using Ollama LLM".to_string(),
// Very permissive schema; callers must supply proper fields
input_schema: json!({
"type": "object",
"properties": {
"messages": {"type": "array"},
"temperature": {"type": ["number", "null"]},
"max_tokens": {"type": ["integer", "null"]},
"model": {"type": "string"},
"stream": {"type": "boolean"}
},
"required": ["messages", "model", "stream"]
}),
requires_network: true,
requires_filesystem: vec![],
}
}
async fn handle_generate_text(args: GenerateTextArgs) -> Result<String, RpcError> {
// Create provider with default local Ollama URL
let provider = OllamaProvider::new("http://localhost:11434")
.map_err(|e| RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e)))?;
let parameters = ChatParameters {
temperature: args.temperature,
max_tokens: args.max_tokens.map(|v| v as u32),
stream: args.stream,
extra: HashMap::new(),
};
let request = ChatRequest {
model: args.model,
messages: args.messages,
parameters,
tools: None,
};
// Use streaming API and collect output
let mut stream = provider
.chat_stream(request)
.await
.map_err(|e| RpcError::internal_error(format!("Chat request failed: {}", e)))?;
let mut content = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(resp) => {
content.push_str(&resp.message.content);
if resp.is_final {
break;
}
}
Err(e) => {
return Err(RpcError::internal_error(format!("Stream error: {}", e)));
}
}
}
Ok(content)
}
async fn handle_request(req: &RpcRequest) -> Result<Value, RpcError> {
match req.method.as_str() {
methods::INITIALIZE => {
let params = req
.params
.as_ref()
.ok_or_else(|| RpcError::invalid_params("Missing params for initialize"))?;
let init: InitializeParams = serde_json::from_value(params.clone())
.map_err(|e| RpcError::invalid_params(format!("Invalid init params: {}", e)))?;
if !init.protocol_version.eq(PROTOCOL_VERSION) {
return Err(RpcError::new(
ErrorCode::INVALID_REQUEST,
format!(
"Incompatible protocol version. Client: {}, Server: {}",
init.protocol_version, PROTOCOL_VERSION
),
));
}
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
server_info: ServerInfo {
name: "owlen-mcp-llm-server".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: ServerCapabilities {
supports_tools: Some(true),
supports_resources: Some(false),
supports_streaming: Some(true),
},
};
Ok(serde_json::to_value(result).unwrap())
}
methods::TOOLS_LIST => {
let desc = generate_text_descriptor();
Ok(json!([desc]))
}
// 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| {
RpcError::internal_error(format!("Failed to init OllamaProvider: {}", e))
})?;
let models = provider
.list_models()
.await
.map_err(|e| RpcError::internal_error(format!("Failed to list models: {}", e)))?;
Ok(serde_json::to_value(models).unwrap())
}
methods::TOOLS_CALL => {
// For streaming we will send incremental notifications directly from here.
// The caller (main loop) will handle writing the final response.
Err(RpcError::internal_error(
"TOOLS_CALL should be handled in main loop for streaming",
))
}
_ => Err(RpcError::method_not_found(&req.method)),
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let root = env::current_dir()?; // not used but kept for parity
let mut stdin = io::BufReader::new(io::stdin());
let mut stdout = io::stdout();
loop {
let mut line = String::new();
match stdin.read_line(&mut line).await {
Ok(0) => break,
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 id = req.id.clone();
// Streaming tool calls (generate_text) are handled specially to emit incremental notifications.
if req.method == methods::TOOLS_CALL {
// Parse the tool call
let params = match &req.params {
Some(p) => p,
None => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params("Missing params for tool call"),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let call: McpToolCall = match serde_json::from_value(params.clone()) {
Ok(c) => c,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params(format!("Invalid tool call: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Dispatch based on the requested tool name.
// Debug tool name for troubleshooting
eprintln!("Tool name received: {}", call.name);
// Handle resources tools manually.
if call.name.starts_with("resources/get") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("");
match std::fs::read_to_string(path) {
Ok(content) => {
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(content),
metadata: HashMap::new(),
duration_ms: 0,
};
let final_resp = RpcResponse::new(
id.clone(),
serde_json::to_value(response).unwrap(),
);
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Failed to read file: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
}
}
if call.name.starts_with("resources/list") {
let path = call
.arguments
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
match std::fs::read_dir(path) {
Ok(entries) => {
let mut names = Vec::new();
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(names),
metadata: HashMap::new(),
duration_ms: 0,
};
let final_resp = RpcResponse::new(
id.clone(),
serde_json::to_value(response).unwrap(),
);
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Failed to list dir: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
}
}
// Expect generate_text tool for the remaining path.
if call.name != "generate_text" {
let err_resp =
RpcErrorResponse::new(id.clone(), RpcError::tool_not_found(&call.name));
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
let args: GenerateTextArgs =
match serde_json::from_value(call.arguments.clone()) {
Ok(a) => a,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::invalid_params(format!("Invalid arguments: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Initialize Ollama provider and start streaming
let provider = match OllamaProvider::new("http://localhost:11434") {
Ok(p) => p,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!(
"Failed to init OllamaProvider: {}",
e
)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
let parameters = ChatParameters {
temperature: args.temperature,
max_tokens: args.max_tokens.map(|v| v as u32),
stream: true,
extra: HashMap::new(),
};
let request = ChatRequest {
model: args.model,
messages: args.messages,
parameters,
tools: None,
};
let mut stream = match provider.chat_stream(request).await {
Ok(s) => s,
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Chat request failed: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
};
// Accumulate full content while sending incremental progress notifications
let mut final_content = String::new();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(resp) => {
// Append chunk to the final content buffer
final_content.push_str(&resp.message.content);
// Emit a progress notification for the UI
let notif = RpcNotification::new(
"tools/call/progress",
Some(json!({ "content": resp.message.content })),
);
let s = serde_json::to_string(&notif)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
if resp.is_final {
break;
}
}
Err(e) => {
let err_resp = RpcErrorResponse::new(
id.clone(),
RpcError::internal_error(format!("Stream error: {}", e)),
);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
break;
}
}
}
// After streaming, send the final tool response containing the full content
let final_output = final_content.clone();
let response = McpToolResponse {
name: call.name,
success: true,
output: json!(final_output),
metadata: HashMap::new(),
duration_ms: 0,
};
let final_resp =
RpcResponse::new(id.clone(), serde_json::to_value(response).unwrap());
let s = serde_json::to_string(&final_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
continue;
}
// Nonstreaming requests are handled by the generic handler
match handle_request(&req).await {
Ok(res) => {
let resp = RpcResponse::new(id, res);
let s = serde_json::to_string(&resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
Err(err) => {
let err_resp = RpcErrorResponse::new(id, err);
let s = serde_json::to_string(&err_resp)?;
stdout.write_all(s.as_bytes()).await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
}
}
}
Err(e) => {
eprintln!("Read error: {}", e);
break;
}
}
}
Ok(())
}

View File

@@ -602,13 +602,23 @@ impl Provider for OllamaProvider {
let options = Self::build_options(parameters); let options = Self::build_options(parameters);
let ollama_tools = tools.as_ref().map(|t| Self::convert_tools_to_ollama(t)); // Only send the `tools` field if there is at least one tool.
// An empty array makes Ollama validate tool support and can cause a
// 400 Bad Request for models that do not support tools.
// Currently the `tools` field is omitted for compatibility; the variable is retained
// for potential future use.
let _ollama_tools = tools
.as_ref()
.filter(|t| !t.is_empty())
.map(|t| Self::convert_tools_to_ollama(t));
// Ollama currently rejects any presence of the `tools` field for models that
// do not support function calling. To be safe, we omit the field entirely.
let ollama_request = OllamaChatRequest { let ollama_request = OllamaChatRequest {
model, model,
messages, messages,
stream: false, stream: false,
tools: ollama_tools, tools: None,
options, options,
}; };
@@ -695,13 +705,21 @@ impl Provider for OllamaProvider {
let options = Self::build_options(parameters); let options = Self::build_options(parameters);
let ollama_tools = tools.as_ref().map(|t| Self::convert_tools_to_ollama(t)); // Only include the `tools` field if there is at least one tool.
// Sending an empty tools array causes Ollama to reject the request for
// models without tool support (400 Bad Request).
// Retain tools conversion for possible future extensions, but silence unused warnings.
let _ollama_tools = tools
.as_ref()
.filter(|t| !t.is_empty())
.map(|t| Self::convert_tools_to_ollama(t));
// Omit the `tools` field for compatibility with models lacking tool support.
let ollama_request = OllamaChatRequest { let ollama_request = OllamaChatRequest {
model, model,
messages, messages,
stream: true, stream: true,
tools: ollama_tools, tools: None,
options, options,
}; };

View File

@@ -14,7 +14,7 @@ use uuid::Uuid;
use crate::config; use crate::config;
use crate::events::Event; use crate::events::Event;
use owlen_ollama::OllamaProvider; use owlen_core::mcp::remote_client::RemoteMcpClient;
use std::collections::{BTreeSet, HashSet}; use std::collections::{BTreeSet, HashSet};
use std::sync::Arc; use std::sync::Arc;
@@ -2195,20 +2195,41 @@ impl ChatApp {
continue; continue;
} }
match OllamaProvider::from_config(&provider_cfg, Some(&general)) { // Separate handling based on provider type.
Ok(provider) => match provider.list_models().await { if provider_type == "ollama" {
Ok(mut provider_models) => { // Local Ollama communicate via the MCP LLM server.
for model in &mut provider_models { match RemoteMcpClient::new() {
model.provider = name.clone(); 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);
} }
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)),
}, }
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
models.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
(models, errors) (models, errors)
} }
@@ -2438,7 +2459,17 @@ impl ChatApp {
}; };
let general = self.controller.config().general.clone(); let general = self.controller.config().general.clone();
let provider = Arc::new(OllamaProvider::from_config(&provider_cfg, Some(&general))?); // 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()?)
} else {
// Ollama Cloud instantiate the direct provider.
use owlen_ollama::OllamaProvider;
let ollama = OllamaProvider::from_config(&provider_cfg, Some(&general))?;
Arc::new(ollama)
};
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();