add event feature to caldav mcp server

This commit is contained in:
2026-05-17 16:57:32 +02:00
parent 2d2875ac32
commit b988f53346
8 changed files with 397 additions and 52 deletions
Generated
+15
View File
@@ -1193,6 +1193,8 @@ dependencies = [
"iso8601",
"nom",
"nom-language",
"rrule",
"thiserror 2.0.18",
"uuid",
]
@@ -2600,6 +2602,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.3"
+170 -1
View File
@@ -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"
+27
View File
@@ -739,6 +739,8 @@ dependencies = [
"iso8601",
"nom",
"nom-language",
"rrule",
"thiserror",
"uuid",
]
@@ -1328,6 +1330,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"
@@ -1412,6 +1426,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"
+1 -1
View File
@@ -17,7 +17,7 @@ uuid = { version = "1.23.0", features = ["v4"], optional = true }
tower-http = { version = "0.6.8", features = ["auth"], optional = true }
thiserror = { version = "2.0.18", optional = true }
libdav = { version = "0.10.3", 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 }
@@ -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<DatePerhapsTime>,
) -> Result<Option<DateTime<chrono::Local>>, 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::<chrono::Local>::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,
)),
}
}
+63
View File
@@ -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<String>,
pub(in crate::caldav) location: Option<String>,
pub(in crate::caldav) start: Option<DateTime<chrono::Local>>,
pub(in crate::caldav) end: Option<DateTime<chrono::Local>>,
}
impl TryFrom<Event> for McpEvent {
type Error = ErrorData;
fn try_from(value: Event) -> Result<Self, Self::Error> {
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<McpEvent> 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
}
}
+74 -3
View File
@@ -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<String>,
}
@@ -128,6 +133,13 @@ struct AddTodoParameters {
todo: McpTodo,
}
#[derive(JsonSchema, Deserialize, Debug)]
struct AddEventParameters {
#[schemars(description = "uses default if null")]
calendar_name: Option<String>,
event: McpEvent,
}
#[tool_router]
impl CalDavHandler {
#[tool(
@@ -152,7 +164,7 @@ impl CalDavHandler {
)]
async fn get_todos(
&self,
parameters: Parameters<GetTodosParameters>,
parameters: Parameters<GetCalendarComponentParameters>,
) -> Result<CallToolResult, ErrorData> {
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<GetCalendarComponentParameters>) -> Result<CallToolResult, ErrorData> {
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<McpEvent> = 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<AddEventParameters>,
) -> Result<CallToolResult, ErrorData> {
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]
+11 -47
View File
@@ -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<String>,
pub(in crate::caldav) summary: String,
pub(in crate::caldav) description: Option<String>,
pub(in crate::caldav) start: Option<DateTime<chrono::Local>>,
pub(in crate::caldav) due: Option<DateTime<chrono::Local>>,
}
impl McpTodo {
fn date_perhaps_time_to_local_dt(
date_perhaps_time: Option<DatePerhapsTime>,
) -> Result<Option<DateTime<chrono::Local>>, 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::<chrono::Local>::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<Todo> for McpTodo {
type Error = ErrorData;
fn try_from(value: Todo) -> Result<Self, Self::Error> {
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<McpTodo> 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<McpTodo> 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)),
));
}