diff --git a/Cargo.lock b/Cargo.lock index a644c9b..0716078 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,6 +1111,8 @@ dependencies = [ "iso8601", "nom", "nom-language", + "rrule", + "thiserror 2.0.18", "uuid", ] @@ -2500,6 +2502,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "rrule" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" +dependencies = [ + "chrono", + "chrono-tz", + "log", + "regex", + "thiserror 2.0.18", +] + [[package]] name = "rtrb" version = "0.3.4" diff --git a/common/Cargo.lock b/common/Cargo.lock index 6c9b211..f835bff 100644 --- a/common/Cargo.lock +++ b/common/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "equivalent" version = "1.0.2" @@ -24,6 +39,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "log" version = "0.4.29" @@ -31,15 +52,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "own_assist_common" +name = "matchers" version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "own_assist_common" +version = "0.3.0" dependencies = [ "log", "serde", "thiserror", "toml", + "tracing-subscriber", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -58,6 +116,23 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "serde" version = "1.0.228" @@ -96,6 +171,21 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "syn" version = "2.0.117" @@ -127,6 +217,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -166,12 +265,82 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "1.0.1" diff --git a/mcp_server_collection/Cargo.lock b/mcp_server_collection/Cargo.lock index 7268d72..8be54ac 100644 --- a/mcp_server_collection/Cargo.lock +++ b/mcp_server_collection/Cargo.lock @@ -658,6 +658,8 @@ dependencies = [ "iso8601", "nom", "nom-language", + "rrule", + "thiserror", "uuid", ] @@ -1206,6 +1208,18 @@ dependencies = [ "syn", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1290,6 +1304,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "rrule" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" +dependencies = [ + "chrono", + "chrono-tz", + "log", + "regex", + "thiserror", +] + [[package]] name = "rustls" version = "0.23.40" diff --git a/mcp_server_collection/Cargo.toml b/mcp_server_collection/Cargo.toml index 4dea3b3..2e0e74d 100644 --- a/mcp_server_collection/Cargo.toml +++ b/mcp_server_collection/Cargo.toml @@ -17,7 +17,7 @@ uuid = { version = "1.23.1", features = ["v4"], optional = true } tower-http = { version = "0.6.11", features = ["auth"], optional = true } thiserror = { version = "2.0.18", optional = true } libdav = { version = "0.10.5", optional = true } -icalendar = { version = "0.17.10", optional = true, features = ["chrono-tz"] } +icalendar = { version = "0.17.10", optional = true, features = ["chrono-tz", "recurrence"] } hyper-util = { version = "0.1.20", optional = true } http = { version = "1.4.0", optional = true } hyper-rustls = { version = "0.27.7", optional = true } diff --git a/mcp_server_collection/src/caldav/datetime_conversion.rs b/mcp_server_collection/src/caldav/datetime_conversion.rs new file mode 100644 index 0000000..112279d --- /dev/null +++ b/mcp_server_collection/src/caldav/datetime_conversion.rs @@ -0,0 +1,36 @@ +use chrono::{DateTime, NaiveTime, TimeZone}; +use icalendar::DatePerhapsTime; +use rmcp::ErrorData; +use rmcp::model::ErrorCode; + +pub(in crate::caldav) fn date_perhaps_time_to_local_dt( + date_perhaps_time: Option, +) -> Result>, ErrorData> { + use DatePerhapsTime; + + if date_perhaps_time.is_none() { + return Ok(None); + } + + use icalendar::CalendarDateTime::*; + let local_datetime = match date_perhaps_time.unwrap() { + DatePerhapsTime::DateTime(datetime) => match datetime { + Floating(naive_dt) => chrono::Local.from_local_datetime(&naive_dt).single(), // assuming local timezone + timezone_dt => timezone_dt + .try_into_utc() + .map(DateTime::::from), + }, + DatePerhapsTime::Date(date) => chrono::Local + .from_local_datetime(&date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())) + .single(), // assuming local timezone + }; + + match local_datetime { + Some(local_datetime) => Ok(Some(local_datetime)), + None => Err(ErrorData::new( + ErrorCode::INVALID_PARAMS, + "DateTime conversion failed", + None, + )), + } +} diff --git a/mcp_server_collection/src/caldav/event.rs b/mcp_server_collection/src/caldav/event.rs new file mode 100644 index 0000000..e215607 --- /dev/null +++ b/mcp_server_collection/src/caldav/event.rs @@ -0,0 +1,63 @@ +use crate::caldav::datetime_conversion::date_perhaps_time_to_local_dt; +use chrono::DateTime; +use icalendar::Trigger; +use icalendar::{Alarm, Component, Event, EventLike, Related}; +use rmcp::schemars::JsonSchema; +use rmcp::{ErrorData, schemars}; +use serde::{Deserialize, Serialize}; + +#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] +pub(in crate::caldav) struct McpEvent { + pub(in crate::caldav) summary: String, + pub(in crate::caldav) description: Option, + pub(in crate::caldav) location: Option, + pub(in crate::caldav) start: Option>, + pub(in crate::caldav) end: Option>, +} + +impl TryFrom for McpEvent { + type Error = ErrorData; + + fn try_from(value: Event) -> Result { + Ok(McpEvent { + summary: value.get_summary().map(String::from).unwrap_or_default(), + description: value.get_description().map(String::from), + location: value.get_location().map(String::from), + start: date_perhaps_time_to_local_dt(value.get_start())?, + end: date_perhaps_time_to_local_dt(value.get_end())?, + }) + } +} + +impl From for Event { + fn from(value: McpEvent) -> Self { + let mut event = Event::new(); + + event.summary(value.summary.as_str()); + + if let Some(description) = value.description { + event.description(description.as_str()); + } + + if let Some(start) = value.start { + event.starts(start.to_utc()); + } + + if let Some(end) = value.end { + event.ends(end.to_utc()); + } + + if let Some(location) = value.location { + event.location(location.as_str()); + } + + event.alarm(Alarm::display( + event + .get_summary() + .unwrap_or(event.get_summary().unwrap_or_default()), + Trigger::Duration(chrono::Duration::zero(), Some(Related::Start)), + )); + + event + } +} diff --git a/mcp_server_collection/src/caldav/mod.rs b/mcp_server_collection/src/caldav/mod.rs index 8f21a1b..19c2800 100644 --- a/mcp_server_collection/src/caldav/mod.rs +++ b/mcp_server_collection/src/caldav/mod.rs @@ -1,3 +1,5 @@ +use tracing::Level; +use chrono::{Duration, Local}; use crate::caldav::client::{ AuthorizedCaldavClient, find_calendars, get_caldav_client, get_components, upload_components, }; @@ -6,7 +8,7 @@ use crate::server_handler::{ McpServerHandler, McpServerHandlerError, get_additional_property, get_property_as_string, }; use http::Uri; -use icalendar::Todo; +use icalendar::{Event, EventLike, Todo}; use libdav::dav::FoundCollection; use rmcp::handler::server::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; @@ -18,8 +20,11 @@ use serde::Deserialize; use std::str::FromStr; use toml::Value; use toml::map::Map; +use crate::caldav::event::McpEvent; mod client; +mod datetime_conversion; +mod event; mod todo; #[derive(Debug, Clone)] @@ -116,7 +121,7 @@ impl CalDavHandler { } #[derive(JsonSchema, Deserialize, Debug)] -struct GetTodosParameters { +struct GetCalendarComponentParameters { #[schemars(description = "uses default if null")] calendar_name: Option, } @@ -128,6 +133,13 @@ struct AddTodoParameters { todo: McpTodo, } +#[derive(JsonSchema, Deserialize, Debug)] +struct AddEventParameters { + #[schemars(description = "uses default if null")] + calendar_name: Option, + event: McpEvent, +} + #[tool_router] impl CalDavHandler { #[tool( @@ -152,7 +164,7 @@ impl CalDavHandler { )] async fn get_todos( &self, - parameters: Parameters, + parameters: Parameters, ) -> Result { let calendar = self.get_calendar_by_name(parameters.0.calendar_name)?; @@ -182,6 +194,65 @@ impl CalDavHandler { Ok(CallToolResult::success(vec![])) } + + #[tool(description = "gets events of next 7 days")] + async fn get_upcoming_events(&self, parameters: Parameters) -> Result { + const DAYS_DELTA: Duration = Duration::days(7); + const RECURRENCE_LIMIT: u16 = 10; + + let calendar = self.get_calendar_by_name(parameters.0.calendar_name)?; + + let components = get_components(&self.client, &calendar).await?; + let mut events: Vec = Vec::new(); + + for component in components { + if let Some(event_component) = component.as_event() { + let event: McpEvent = event_component.clone().try_into()?; + + let lower_bound = Local::now().with_timezone(&icalendar::Tz::Local(chrono::Local)); + let upper_bound = lower_bound + DAYS_DELTA; + let recurrence = event_component.get_recurrence(); + + match recurrence { + Ok(recurrence) => { + let recurrence_result = recurrence.after(lower_bound).before(upper_bound).all(RECURRENCE_LIMIT); + + for date in recurrence_result.dates { + let mut event_instance = event.clone(); + event_instance.start = Some(date.with_timezone(&chrono::Local{})); + + if let Some(start) = event.start && let Some(end) = event.end { + let event_duration = end-start; + event_instance.end = Some(event_instance.start.unwrap() + event_duration); + } + + events.push(event_instance); + } + }, + Err(error) => { + tracing::event!(Level::INFO, "recurrence error occured. Event '{event:?}' is ignored: {error}"); + } + } + } + } + + events.sort_by_key(|event|event.start); + + Ok(CallToolResult::structured(serde_json::json!(events))) + } + + #[tool(description = "add a new event to a calendar")] + async fn add_event( + &self, + parameters: Parameters, + ) -> Result { + let calendar = self.get_calendar_by_name(parameters.0.calendar_name)?; + + let event: Event = parameters.0.event.into(); + upload_components(&self.client, &calendar, vec![event]).await?; + + Ok(CallToolResult::success(vec![])) + } } #[tool_handler] diff --git a/mcp_server_collection/src/caldav/todo.rs b/mcp_server_collection/src/caldav/todo.rs index bc80ae7..e7a0092 100644 --- a/mcp_server_collection/src/caldav/todo.rs +++ b/mcp_server_collection/src/caldav/todo.rs @@ -1,62 +1,28 @@ -use chrono::{DateTime, NaiveTime, TimeZone}; -use icalendar::{Alarm, Component, DatePerhapsTime, EventLike, Related, Todo, Trigger}; +use crate::caldav::datetime_conversion::date_perhaps_time_to_local_dt; +use chrono::DateTime; +use icalendar::{Alarm, Component, EventLike, Related, Todo, Trigger}; use rmcp::ErrorData; -use rmcp::model::ErrorCode; use rmcp::schemars; use rmcp::schemars::JsonSchema; use serde::{Deserialize, Serialize}; #[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)] pub(in crate::caldav) struct McpTodo { - pub(in crate::caldav) summary: Option, + pub(in crate::caldav) summary: String, pub(in crate::caldav) description: Option, pub(in crate::caldav) start: Option>, pub(in crate::caldav) due: Option>, } -impl McpTodo { - fn date_perhaps_time_to_local_dt( - date_perhaps_time: Option, - ) -> Result>, ErrorData> { - use DatePerhapsTime; - - if date_perhaps_time.is_none() { - return Ok(None); - } - - use icalendar::CalendarDateTime::*; - let local_datetime = match date_perhaps_time.unwrap() { - DatePerhapsTime::DateTime(datetime) => match datetime { - Floating(naive_dt) => chrono::Local.from_local_datetime(&naive_dt).single(), // assuming local timezone - timezone_dt => timezone_dt - .try_into_utc() - .map(DateTime::::from), - }, - DatePerhapsTime::Date(date) => chrono::Local - .from_local_datetime(&date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())) - .single(), // assuming local timezone - }; - - match local_datetime { - Some(local_datetime) => Ok(Some(local_datetime)), - None => Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - "DateTime conversion failed", - None, - )), - } - } -} - impl TryFrom for McpTodo { type Error = ErrorData; fn try_from(value: Todo) -> Result { Ok(McpTodo { - summary: value.get_summary().map(|s| s.to_string()), - description: value.get_description().map(|s| s.to_string()), - start: Self::date_perhaps_time_to_local_dt(value.get_start())?, - due: Self::date_perhaps_time_to_local_dt(value.get_due())?, + summary: value.get_summary().map(String::from).unwrap_or_default(), + description: value.get_description().map(String::from), + start: date_perhaps_time_to_local_dt(value.get_start())?, + due: date_perhaps_time_to_local_dt(value.get_due())?, }) } } @@ -65,9 +31,7 @@ impl From for Todo { fn from(value: McpTodo) -> Self { let mut todo = Todo::new(); - if let Some(summary) = value.summary { - todo.summary(summary.as_str()); - } + todo.summary(value.summary.as_str()); if let Some(description) = value.description { todo.description(description.as_str()); @@ -84,8 +48,8 @@ impl From for Todo { for related in [Related::Start, Related::End] { todo.alarm(Alarm::display( todo.get_summary() - .unwrap_or(todo.get_description().unwrap_or("")), - Trigger::Duration(chrono::Duration::new(0, 0).unwrap(), Some(related)), + .unwrap_or(todo.get_description().unwrap_or_default()), + Trigger::Duration(chrono::Duration::zero(), Some(related)), )); } diff --git a/own_mcp/src/mcp/chat.rs b/own_mcp/src/mcp/chat.rs index 9bf57e1..336e374 100644 --- a/own_mcp/src/mcp/chat.rs +++ b/own_mcp/src/mcp/chat.rs @@ -151,7 +151,7 @@ impl AgentChat { /// let result = AgentChat::parse_tool_name("example:get_foo".to_string()); /// assert_eq!(result.unwrap(), ("example".to_string(), "get_foo".to_string())) /// ``` - fn parse_tool_name(name: String) -> Result<(String, String), ChatError> { + pub fn parse_tool_name(name: String) -> Result<(String, String), ChatError> { let (mcp_server_name, tool_name) = name .split_once(":") .ok_or(ChatError::FunctionParseError(name.clone()))?;