add event feature to caldav mcp server
This commit is contained in:
@@ -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,
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user