diff --git a/.gitignore b/.gitignore index aac20bb..d4ea06e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ server.toml temporary_audio cli/src/human_interface/.AUTH_HEADER .idea +/own_mcp/src/mcp/llmclient/mistral/.MISTRAL_API_KEY diff --git a/Cargo.lock b/Cargo.lock index 335397c..4364b2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,7 +1551,7 @@ dependencies = [ "icalendar", "libdav", "own_assist_common 0.3.0", - "rmcp", + "rmcp 2.2.0", "serde", "thiserror 2.0.19", "tokio", @@ -1577,7 +1577,7 @@ dependencies = [ "icalendar", "libdav", "own_assist_common 0.3.0", - "rmcp", + "rmcp 2.2.0", "serde", "thiserror 2.0.19", "tokio", @@ -1999,8 +1999,8 @@ dependencies = [ "mcp_server_collection 0.2.2 (sparse+https://git.mboemer.de/api/packages/milan/cargo/)", "ollama-rs", "own_assist_common 0.4.1", - "own_mcp 0.1.1 (sparse+https://git.mboemer.de/api/packages/milan/cargo/)", - "rmcp", + "own_mcp 0.1.1", + "rmcp 2.2.0", "rodio", "serde", "thiserror 2.0.19", @@ -2050,25 +2050,6 @@ dependencies = [ "url", ] -[[package]] -name = "own_mcp" -version = "0.1.1" -dependencies = [ - "bytes", - "cpal 0.18.1", - "env_logger", - "hound", - "log", - "ollama-rs", - "rand 0.10.2", - "reqwest 0.13.4", - "rmcp", - "serde", - "thiserror 2.0.19", - "tokio", - "url", -] - [[package]] name = "own_mcp" version = "0.1.1" @@ -2083,7 +2064,28 @@ dependencies = [ "ollama-rs", "rand 0.10.2", "reqwest 0.13.4", - "rmcp", + "rmcp 2.2.0", + "serde", + "thiserror 2.0.19", + "tokio", + "url", +] + +[[package]] +name = "own_mcp" +version = "0.2.0" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "cpal 0.18.1", + "env_logger", + "hound", + "log", + "ollama-rs", + "rand 0.10.2", + "reqwest 0.13.4", + "rmcp 3.0.0", "serde", "thiserror 2.0.19", "tokio", @@ -2531,7 +2533,7 @@ dependencies = [ "process-wrap", "rand 0.10.2", "reqwest 0.13.4", - "rmcp-macros", + "rmcp-macros 2.2.0", "schemars", "serde", "serde_json", @@ -2546,6 +2548,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "rmcp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd2b6dd3b18129368955f32661a7718969e8c152c7d8866434c09cf15a512e0" +dependencies = [ + "async-trait", + "base64 0.23.0", + "bytes", + "chrono", + "futures", + "http", + "oauth2", + "pastey", + "pin-project-lite", + "process-wrap", + "reqwest 0.13.4", + "rmcp-macros 3.0.0", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", +] + [[package]] name = "rmcp-macros" version = "2.2.0" @@ -2559,6 +2592,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "rmcp-macros" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aa4b9345795260a43fc23d6d05e096407c8b953903f673af7c4404b49fb2d6" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + [[package]] name = "rodio" version = "0.22.2" diff --git a/cli/src/main.rs b/cli/src/main.rs index 94559ee..69909f4 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -102,12 +102,14 @@ async fn main() { let ollama = ollama_config.ollama(); let model_name = &ollama_config.model_config.name; - ollama_config.create_model(Some(tlt!("model_download_needed"))).await.inspect_err(exit_msg!(format!( + ollama_config + .create_model(Some(tlt!("model_download_needed"))) + .await + .inspect_err(exit_msg!(format!( "failed creating ollama model `{model_name}`" ))) .unwrap(); - let mcp_clients = config .mcp_clients() .await diff --git a/own_mcp/Cargo.toml b/own_mcp/Cargo.toml index 43bcdaa..515ff24 100644 --- a/own_mcp/Cargo.toml +++ b/own_mcp/Cargo.toml @@ -1,20 +1,22 @@ [package] name = "own_mcp" -version = "0.1.1" +version = "0.2.0" edition = "2024" license = "GPL-3.0" [dependencies] -ollama-rs = {version = "0.3.4", features = ["macros", "headers"]} +ollama-rs = {version = "0.3.6", features = ["macros", "headers"]} reqwest = { version = "0.13.2", features = ["stream", "multipart", "form"] } tokio = { version = "1.50.0", features = ["rt", "rt-multi-thread", "macros"] } -rmcp = {version="2.2.0", features = ["transport-streamable-http-client-reqwest", "reqwest", "client", "auth", "transport-child-process"]} +rmcp = {version="3.0.0", features = ["transport-streamable-http-client-reqwest", "reqwest", "client", "auth", "transport-child-process"]} log = {version = "0.4.29"} -env_logger = "0.11.10" -serde = { version = "1.0.228", features = ["derive"] } -thiserror = "2.0.17" +env_logger = "0.11.11" +serde = { version = "1.0.229", features = ["derive"] } +thiserror = "2.0.19" url = "2.5.8" -rand = "0.10.0" +rand = "0.10.2" cpal = "0.18.1" hound = "3.5.1" -bytes = "1.11.1" \ No newline at end of file +bytes = "1.11.1" +async-trait = "0.1.91" +chrono = "0.4.45" \ No newline at end of file diff --git a/own_mcp/src/mcp/chat.rs b/own_mcp/src/mcp/chat.rs index e36945e..bb4a178 100644 --- a/own_mcp/src/mcp/chat.rs +++ b/own_mcp/src/mcp/chat.rs @@ -1,9 +1,7 @@ use crate::mcp::MCPClient; -use ollama_rs::Ollama; -use ollama_rs::error::OllamaError; +use crate::mcp::llmclient::{LLMClient, LLMError}; use ollama_rs::generation::chat::ChatMessage; use ollama_rs::generation::chat::request::ChatMessageRequest; -use ollama_rs::generation::completion::request::GenerationRequest; use ollama_rs::generation::tools::{ToolCall, ToolInfo}; use rmcp::ServiceError; use rmcp::model::{CallToolRequestParams, CallToolResult}; @@ -77,7 +75,7 @@ impl MCPServerData { #[derive(Debug)] pub struct AgentChat { - ollama_client: Ollama, + llm_client: Box, model: String, mcp_servers: HashMap, message_history: Vec, @@ -86,7 +84,7 @@ pub struct AgentChat { #[derive(Debug, Error)] pub enum ChatError { #[error(transparent)] - OllamaError(#[from] OllamaError), + LLMError(#[from] LLMError), #[error(transparent)] ServiceError(#[from] ServiceError), #[error("the function name could not be parsed")] @@ -113,7 +111,7 @@ pub struct ToolUsage { impl AgentChat { pub async fn new( - ollama_client: Ollama, + llm_client: Box, model: String, mcp_clients: HashMap, ) -> Result { @@ -126,17 +124,8 @@ impl AgentChat { servers.insert(server.name.clone(), server); } - // lets the ollama server load the model - let ollama_client_clone = ollama_client.clone(); - let model_clone = model.clone(); - tokio::spawn(async move { - let _ = ollama_client_clone - .generate(GenerationRequest::new(model_clone, "")) - .await; - }); - Ok(Self { - ollama_client, + llm_client, model, mcp_servers: servers, message_history: history, @@ -232,7 +221,7 @@ impl AgentChat { log::debug!("all tools: {all_tools:#?}"); let mut response = self - .ollama_client + .llm_client .send_chat_messages_with_history( &mut self.message_history, ChatMessageRequest::new(self.model.clone(), vec![ChatMessage::user(user_message)]) @@ -288,7 +277,7 @@ impl AgentChat { } response = self - .ollama_client + .llm_client .send_chat_messages_with_history( &mut self.message_history, ChatMessageRequest::new(self.model.clone(), Vec::new()) @@ -375,7 +364,7 @@ impl AgentChat { pub fn without_history(self) -> Self { Self { - ollama_client: self.ollama_client, + llm_client: self.llm_client, model: self.model, mcp_servers: self.mcp_servers, message_history: vec![], @@ -392,6 +381,7 @@ mod tests { use super::*; use crate::mcp::chat::RestrictedTool; use crate::mcp::get_client; + use ollama_rs::Ollama; use rmcp::model::Implementation; use std::collections::HashMap; @@ -421,9 +411,13 @@ mod tests { .unwrap(), )]); - let mut chat = AgentChat::new(ollama, "lfm2.5-thinking:1.2b".to_string(), mcp_clients) - .await - .unwrap(); + let mut chat = AgentChat::new( + Box::new(ollama), + "lfm2.5-thinking:1.2b".to_string(), + mcp_clients, + ) + .await + .unwrap(); let tools: Vec = chat.get_all_tools().cloned().collect(); assert_eq!(tools.len(), 1); diff --git a/own_mcp/src/mcp/llmclient/mistral/mod.rs b/own_mcp/src/mcp/llmclient/mistral/mod.rs new file mode 100644 index 0000000..cb9de65 --- /dev/null +++ b/own_mcp/src/mcp/llmclient/mistral/mod.rs @@ -0,0 +1,216 @@ +use crate::mcp::llmclient::{LLMClient, LLMError}; +use async_trait::async_trait; +use ollama_rs::generation::chat::ChatMessage as OllamaChatMessage; +use ollama_rs::generation::chat::request::ChatMessageRequest as OllamaChatMessageRequest; +use ollama_rs::generation::chat::{ChatMessageResponse, MessageRole}; +use ollama_rs::generation::tools::{ToolCall, ToolInfo}; +use ollama_rs::history::ChatHistory; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +pub struct Mistral { + api_key: String, + system_prompt: Option, +} + +impl Mistral { + pub fn new(api_key: String) -> Mistral { + Mistral { + api_key, + system_prompt: None, + } + } + + pub fn with_system_prompt(mut self, system_prompt: Option) -> Mistral { + self.system_prompt = system_prompt; + self + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Message { + content: String, + role: MessageRole, + #[serde( + skip_serializing_if = "Vec::is_empty", + default = "Vec::new", + deserialize_with = "parse_tool_calls" + )] + tool_calls: Vec, +} + +fn parse_tool_calls<'de, D>(d: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Deserialize::deserialize(d).map(|x: Option<_>| x.unwrap_or(Vec::new())) +} + +impl From for Message { + fn from(message: OllamaChatMessage) -> Message { + Message { + content: message.content, + role: message.role, + tool_calls: message.tool_calls, + } + } +} + +impl From for OllamaChatMessage { + fn from(message: Message) -> OllamaChatMessage { + OllamaChatMessage { + role: message.role, + content: message.content, + tool_calls: message.tool_calls, + images: None, + thinking: None, + } + } +} + +#[derive(Debug, Serialize)] +struct ChatCompletionRequest { + messages: Vec, + model: String, + tools: Vec, +} + +impl From for ChatCompletionRequest { + fn from(request: OllamaChatMessageRequest) -> ChatCompletionRequest { + ChatCompletionRequest { + messages: request.messages.into_iter().map(|m| m.into()).collect(), + model: request.model_name, + tools: request.tools, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct ChatCompletionChoice { + message: Message, +} + +#[derive(Debug, Clone, Deserialize)] +struct ChatCompletionResponse { + choices: Vec, + model: String, + #[serde(rename = "created")] + created_unix_seconds: i64, +} + +impl TryFrom for ChatMessageResponse { + type Error = LLMError; + + fn try_from(value: ChatCompletionResponse) -> Result { + let created = chrono::DateTime::from_timestamp(value.created_unix_seconds, 0) + .ok_or(LLMError::Other("failed converting timestamp".to_string()))?; + + Ok(ChatMessageResponse { + model: value.model, + created_at: created.to_rfc3339(), + message: value + .choices + .first() + .ok_or(LLMError::Other("answer does not exist".to_string()))? + .message + .clone() + .into(), + logprobs: None, + done: false, + final_data: None, + }) + } +} + +#[async_trait] +impl LLMClient for Mistral { + async fn send_chat_messages_with_history( + &self, + history: &mut Vec, + mut request: ollama_rs::generation::chat::request::ChatMessageRequest, + ) -> Result { + if let Some(first_message) = history.first() // add system prompt as first message + && first_message.role == MessageRole::System + && let Some(system_prompt) = self.system_prompt.clone() + { + history.insert( + 0, + OllamaChatMessage::new(MessageRole::System, system_prompt), + ); + } + + history.append(&mut request.messages); + + request.messages = history.messages().to_vec(); + + let client = reqwest::Client::new(); + let chat_completion_request: ChatCompletionRequest = request.into(); + let response = client + .post("https://api.mistral.ai/v1/chat/completions") + .header("Authorization", format!("Bearer {}", self.api_key)) + .json(&chat_completion_request) + .send() + .await + .map_err(|e| LLMError::NetworkError(e.to_string()))?; + + let response = response + .error_for_status() + .map_err(|e| LLMError::Other(e.to_string()))?; + + let completion_response: ChatCompletionResponse = response + .json() + .await + .map_err(|e| LLMError::Other(e.to_string()))?; + let chat_message_response: ChatMessageResponse = completion_response.try_into()?; + + Ok(chat_message_response) + } + + async fn preload_model(&self, _model: &str) { + // do nothing + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ollama_rs::generation::chat::request::ChatMessageRequest; + use ollama_rs::generation::chat::{ChatMessage, MessageRole}; + use rmcp::schemars; + + #[derive(schemars::JsonSchema)] + struct BeepToolParameters { + location: String, + } + + #[tokio::test] + async fn sending_message() { + let client = Mistral::new(include_str!(".MISTRAL_API_KEY").to_string()); + + let mut history = vec![]; + + dbg!( + client + .send_chat_messages_with_history( + &mut history, + ChatMessageRequest::new( + "mistral-medium-3-5".to_string(), + vec![ChatMessage::new( + MessageRole::User, + "Mach ein Geräusch in der Küche".to_string() + )], + ) + .tools(vec![ToolInfo { + tool_type: ollama_rs::generation::tools::ToolType::Function, + function: ollama_rs::generation::tools::ToolFunctionInfo { + name: "make_sound".to_string(), + description: "alerts the user with a beep".to_string(), + parameters: schemars::schema_for!(BeepToolParameters), + } + }]), + ) + .await + .unwrap() + ); + } +} diff --git a/own_mcp/src/mcp/llmclient/mod.rs b/own_mcp/src/mcp/llmclient/mod.rs new file mode 100644 index 0000000..2765b0b --- /dev/null +++ b/own_mcp/src/mcp/llmclient/mod.rs @@ -0,0 +1,27 @@ +pub mod mistral; +pub mod ollama; + +use async_trait::async_trait; +use ollama_rs::generation::chat::request::ChatMessageRequest; +use ollama_rs::generation::chat::{ChatMessage, ChatMessageResponse}; +use std::fmt::Debug; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum LLMError { + #[error("network error: {0}")] + NetworkError(String), + #[error("llm error: {0}")] + Other(String), +} + +#[async_trait] +pub trait LLMClient: Debug + Send + Sync { + async fn send_chat_messages_with_history( + &self, + history: &mut Vec, + request: ChatMessageRequest, + ) -> Result; + + async fn preload_model(&self, model: &str); +} diff --git a/own_mcp/src/mcp/llmclient/ollama.rs b/own_mcp/src/mcp/llmclient/ollama.rs new file mode 100644 index 0000000..dfe6c35 --- /dev/null +++ b/own_mcp/src/mcp/llmclient/ollama.rs @@ -0,0 +1,38 @@ +use crate::mcp::llmclient::{LLMClient, LLMError}; +use async_trait::async_trait; +use ollama_rs::Ollama; +use ollama_rs::error::OllamaError; +use ollama_rs::generation::chat::request::ChatMessageRequest; +use ollama_rs::generation::chat::{ChatMessage, ChatMessageResponse}; +use ollama_rs::generation::completion::request::GenerationRequest; + +impl From for LLMError { + fn from(err: OllamaError) -> LLMError { + match err { + OllamaError::ToolCallError(e) => LLMError::Other(e.to_string()), + OllamaError::JsonError(e) => LLMError::Other(e.to_string()), + OllamaError::ReqwestError(e) => LLMError::NetworkError(e.to_string()), + OllamaError::InternalError(e) => LLMError::Other(e.message), + OllamaError::Other(e) => LLMError::Other(e.to_string()), + } + } +} + +#[async_trait] +impl LLMClient for Ollama { + async fn send_chat_messages_with_history( + &self, + history: &mut Vec, + request: ChatMessageRequest, + ) -> Result { + Ok(self + .send_chat_messages_with_history(history, request) + .await?) + } + + async fn preload_model(&self, model: &str) { + let _ = self + .generate(GenerationRequest::new(model.to_string(), "")) + .await; + } +} diff --git a/own_mcp/src/mcp/mod.rs b/own_mcp/src/mcp/mod.rs index 58e8adc..3fe4c67 100644 --- a/own_mcp/src/mcp/mod.rs +++ b/own_mcp/src/mcp/mod.rs @@ -1,6 +1,9 @@ pub mod chat; +mod llmclient; mod translation; +pub use llmclient::mistral::Mistral; + use rmcp::model::InitializeRequestParams; use rmcp::service::{ClientInitializeError, RunningService}; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; @@ -48,8 +51,10 @@ pub fn guaranteed_mcp_server_name( ) -> String { if let Some(user_specified_name) = user_specified_name { user_specified_name - } else if let Some(peer_info) = client.peer_info() { - peer_info.server_info.name.clone() + } else if let Some(peer_info) = client.peer_info() + && let Some(server_info) = &peer_info.server_info + { + server_info.name.clone() } else { let random_name = generate_random_mcp_server_name(); log::warn!(