diff --git a/server/src/main/kotlin/at/bitfire/labs/davmcp/di/ToolsModule.kt b/server/src/main/kotlin/at/bitfire/labs/davmcp/di/ToolsModule.kt index 86fae3f..7eeb2bb 100644 --- a/server/src/main/kotlin/at/bitfire/labs/davmcp/di/ToolsModule.kt +++ b/server/src/main/kotlin/at/bitfire/labs/davmcp/di/ToolsModule.kt @@ -20,6 +20,10 @@ object ToolsModule { @IntoSet fun provideListCollectionsTool(listCollectionsTool: ListCollectionsTool): McpTool = listCollectionsTool + @Provides + @IntoSet + fun provideListAllEventsTool(listAllEventsTool: ListAllEventsTool): McpTool = listAllEventsTool + @Provides @IntoSet fun provideQueryByTimeTool(queryEventsByTimeTool: QueryEventsByTimeTool): McpTool = queryEventsByTimeTool diff --git a/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/CommonToolSchema.kt b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/CommonToolSchema.kt index 7f53c91..f981642 100644 --- a/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/CommonToolSchema.kt +++ b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/CommonToolSchema.kt @@ -1,3 +1,4 @@ +import at.bitfire.labs.davmcp.icalendar.simpleEventSchema import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -10,4 +11,22 @@ internal fun JsonObjectBuilder.collectionIdSchema() { "Optional ID of the targeted calendar collection. Must be empty (= default calendar) or a collection ID as returned by collections.list." ) }) +} + +internal fun JsonObjectBuilder.eventListOutputSchema() { + put("events", buildJsonObject { + put("type", "array") + put("items", buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("fileName", buildJsonObject { + put("type", "string") + put("description", "File name of the event (iCalendar)") + }) + put("eventData", buildJsonObject { + simpleEventSchema() + }) + }) + }) + }) } \ No newline at end of file diff --git a/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/EventResponseHandler.kt b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/EventResponseHandler.kt new file mode 100644 index 0000000..c1371bf --- /dev/null +++ b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/EventResponseHandler.kt @@ -0,0 +1,34 @@ +package at.bitfire.labs.davmcp.tools + +import at.bitfire.dav4jvm.ktor.Response +import at.bitfire.dav4jvm.property.caldav.CalendarData +import at.bitfire.labs.davmcp.icalendar.SimpleEvent +import at.bitfire.labs.davmcp.icalendar.SimpleEventConverter +import kotlinx.serialization.Serializable +import javax.inject.Inject + +class EventResponseHandler @Inject constructor( + private val simpleEventConverter: SimpleEventConverter +) { + + fun processCalendarResponse(response: Response, relation: Response.HrefRelation): EventWithName? { + if (relation != Response.HrefRelation.MEMBER) + return null + + val calendarData = response[CalendarData::class.java]?.iCalendar ?: return null + val event = simpleEventConverter.fromICalendar(calendarData) + return event?.let { + EventWithName( + fileName = response.hrefName(), + eventData = it + ) + } + } + + @Serializable + data class EventWithName( + val fileName: String, + val eventData: SimpleEvent + ) + +} \ No newline at end of file diff --git a/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/ListAllEventsTool.kt b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/ListAllEventsTool.kt new file mode 100644 index 0000000..0ab954c --- /dev/null +++ b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/ListAllEventsTool.kt @@ -0,0 +1,87 @@ +package at.bitfire.labs.davmcp.tools + +import at.bitfire.dav4jvm.ktor.DavResource +import at.bitfire.dav4jvm.property.caldav.CalDAV +import at.bitfire.dav4jvm.property.webdav.WebDAV +import at.bitfire.labs.davmcp.HttpClientBuilder +import at.bitfire.labs.davmcp.db.Database +import at.bitfire.labs.davmcp.db.User +import at.bitfire.labs.davmcp.json.McpJson +import collectionIdSchema +import eventListOutputSchema +import io.ktor.http.* +import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection +import io.modelcontextprotocol.kotlin.sdk.types.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import javax.inject.Inject + +class ListAllEventsTool @Inject constructor( + private val database: Database, + private val httpClientBuilder: HttpClientBuilder, + private val eventResponseHandler: EventResponseHandler +) : BaseMcpTool() { + + override fun tool() = Tool( + name = "events.listAll", + description = "List all events in a calendar collection", + inputSchema = ToolSchema( + properties = buildJsonObject { + collectionIdSchema() + }, + required = listOf() + ), + outputSchema = ToolSchema( + properties = buildJsonObject { + eventListOutputSchema() + }, + required = listOf("events") + ), + annotations = ToolAnnotations( + readOnlyHint = true, + destructiveHint = false + ) + ) + + override suspend fun handle(connection: ClientConnection, user: User, request: CallToolRequest): CallToolResult { + val input = McpJson.decodeFromJsonElement( + request.arguments ?: throw IllegalArgumentException("Request arguments are required") + ) + logToolCall("ListAllEventsTool", user, input) + + val service = getCalDavService(database, user) + val collection = resolveCollection(database, service, input.collectionId) + val collectionUrl = Url(collection.url) + + httpClientBuilder.buildFromService(service).use { client -> + val davResource = DavResource(client, collectionUrl) + + val events = mutableListOf() + davResource.propfind(1, WebDAV.GetETag, CalDAV.CalendarData) { response, relation -> + val eventWithName = eventResponseHandler.processCalendarResponse(response, relation) + if (eventWithName != null) + events += eventWithName + } + return CallToolResult( + content = listOf(TextContent(McpJson.encodeToString(events))), + isError = false, + structuredContent = McpJson.encodeToJsonElement(OutputData(events)).jsonObject + ).also { logger.info("Result: $it") } + } + } + + + @Serializable + data class InputData( + val collectionId: Long? = null + ) + + @Serializable + data class OutputData( + val events: List + ) + +} \ No newline at end of file diff --git a/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/QueryEventsByTimeTool.kt b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/QueryEventsByTimeTool.kt index 1761f3c..2323ab6 100644 --- a/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/QueryEventsByTimeTool.kt +++ b/server/src/main/kotlin/at/bitfire/labs/davmcp/tools/QueryEventsByTimeTool.kt @@ -1,17 +1,13 @@ package at.bitfire.labs.davmcp.tools import at.bitfire.dav4jvm.ktor.DavCalendar -import at.bitfire.dav4jvm.ktor.Response import at.bitfire.dav4jvm.property.caldav.CalDAV -import at.bitfire.dav4jvm.property.caldav.CalendarData import at.bitfire.labs.davmcp.HttpClientBuilder import at.bitfire.labs.davmcp.db.Database import at.bitfire.labs.davmcp.db.User -import at.bitfire.labs.davmcp.icalendar.SimpleEvent -import at.bitfire.labs.davmcp.icalendar.SimpleEventConverter -import at.bitfire.labs.davmcp.icalendar.simpleEventSchema import at.bitfire.labs.davmcp.json.McpJson import collectionIdSchema +import eventListOutputSchema import io.ktor.http.* import io.modelcontextprotocol.kotlin.sdk.server.ClientConnection import io.modelcontextprotocol.kotlin.sdk.types.* @@ -24,7 +20,7 @@ import javax.inject.Inject class QueryEventsByTimeTool @Inject constructor( private val database: Database, private val httpClientBuilder: HttpClientBuilder, - private val simpleEventConverter: SimpleEventConverter + private val eventResponseHandler: EventResponseHandler ) : BaseMcpTool() { override fun tool() = Tool( @@ -54,21 +50,7 @@ class QueryEventsByTimeTool @Inject constructor( ), outputSchema = ToolSchema( properties = buildJsonObject { - put("events", buildJsonObject { - put("type", "array") - put("items", buildJsonObject { - put("type", "object") - put("properties", buildJsonObject { - put("fileName", buildJsonObject { - put("type", "string") - put("description", "File name of the event (iCalendar)") - }) - put("eventData", buildJsonObject { - simpleEventSchema() - }) - }) - }) - }) + eventListOutputSchema() }, required = listOf("events") ), @@ -94,18 +76,11 @@ class QueryEventsByTimeTool @Inject constructor( val start: Instant? = input.start?.let { Instant.parse(it) } val end: Instant? = input.end?.let { Instant.parse(it) } - val events = mutableListOf() + val events = mutableListOf() calendar.calendarQuery(Component.VEVENT, start, end, setOf(CalDAV.CalendarData)) { response, relation -> - if (relation != Response.HrefRelation.MEMBER) - return@calendarQuery - - val calendarData = response[CalendarData::class.java]?.iCalendar ?: return@calendarQuery - val event = simpleEventConverter.fromICalendar(calendarData) - if (event != null) - events += EventWithName( - fileName = response.hrefName(), - eventData = event - ) + val eventWithName = eventResponseHandler.processCalendarResponse(response, relation) + if (eventWithName != null) + events += eventWithName } return CallToolResult( content = listOf(TextContent(McpJson.encodeToString(events))), @@ -123,15 +98,9 @@ class QueryEventsByTimeTool @Inject constructor( val end: String? ) - @Serializable - data class EventWithName( - val fileName: String, - val eventData: SimpleEvent - ) - @Serializable data class OutputData( - val events: List + val events: List ) { }