use std::sync::Arc; use application::{ channels::ChannelCommandDeps, library::LibraryCommandDeps, schedule::ScheduleDeps, }; use rmcp::{ ServerHandler, model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}, tool, }; use schemars::JsonSchema; use serde::Deserialize; use uuid::Uuid; use crate::tools::{channels, ical, library, schedule}; const SERVER_NAME: &str = "k-tv-mcp"; #[derive(Clone)] pub struct KTvMcpServer { pub channel_cmd_deps: Arc, pub channel_query: Arc, pub schedule_deps: Arc, pub schedule_query: Arc, pub library_query: Arc, pub library_command_deps: Arc, pub owner_id: Uuid, } #[derive(Debug, Deserialize, JsonSchema)] pub struct GetChannelParams { pub id: String, } #[derive(Debug, Deserialize, JsonSchema)] pub struct CreateChannelParams { pub name: String, pub timezone: String, } #[derive(Debug, Deserialize, JsonSchema)] pub struct UpdateChannelParams { pub id: String, pub name: Option, pub timezone: Option, pub description: Option, pub schedule_config_json: Option, pub gap_filler_json: Option, } #[derive(Debug, Deserialize, JsonSchema)] pub struct DeleteChannelParams { pub id: String, } #[derive(Debug, Deserialize, JsonSchema)] pub struct ChannelIdParam { pub channel_id: String, } #[derive(Debug, Deserialize, JsonSchema)] pub struct SearchMediaParams { pub content_type: Option, pub genres: Option>, pub search_term: Option, pub series_names: Option>, pub collections: Option>, } #[derive(Debug, Deserialize, JsonSchema)] pub struct ListGenresParams { pub content_type: Option, } fn parse_uuid(s: &str) -> Result { s.parse::() .map_err(|_| serde_json::json!({"error": format!("invalid UUID: {s}")}).to_string()) } #[tool(tool_box)] impl KTvMcpServer { #[tool(description = "List all channels owned by the configured user")] async fn list_channels(&self) -> String { channels::list_channels(&self.channel_query, self.owner_id).await } #[tool(description = "Get a channel by UUID")] async fn get_channel(&self, #[tool(aggr)] p: GetChannelParams) -> String { match parse_uuid(&p.id) { Ok(id) => channels::get_channel(&self.channel_query, id).await, Err(e) => e, } } #[tool(description = "Create a new channel with a name and IANA timezone")] async fn create_channel(&self, #[tool(aggr)] p: CreateChannelParams) -> String { channels::create_channel(&self.channel_cmd_deps, self.owner_id, &p.name, &p.timezone).await } #[tool(description = "Update channel name, timezone, description, schedule config, and/or gap_filler")] async fn update_channel(&self, #[tool(aggr)] p: UpdateChannelParams) -> String { let id = match parse_uuid(&p.id) { Ok(id) => id, Err(e) => return e, }; let schedule_config = match p.schedule_config_json { Some(json) => match serde_json::from_str(&json) { Ok(c) => Some(c), Err(e) => { return serde_json::json!({"error": format!("invalid schedule_config_json: {e}")}) .to_string() } }, None => None, }; let gap_filler = match p.gap_filler_json { Some(json) if json == "null" => Some(None), Some(json) => match serde_json::from_str(&json) { Ok(f) => Some(Some(f)), Err(e) => { return serde_json::json!({"error": format!("invalid gap_filler_json: {e}")}) .to_string() } }, None => None, }; channels::update_channel( &self.channel_cmd_deps, channels::UpdateChannelArgs { channel_id: id, owner_id: self.owner_id, name: p.name, timezone: p.timezone, description: p.description, schedule_config, gap_filler, }, ) .await } #[tool(description = "Delete a channel (must be owned by the configured user)")] async fn delete_channel(&self, #[tool(aggr)] p: DeleteChannelParams) -> String { match parse_uuid(&p.id) { Ok(id) => channels::delete_channel(&self.channel_cmd_deps, id, self.owner_id).await, Err(e) => e, } } #[tool(description = "Generate a fresh schedule for the given channel")] async fn generate_schedule(&self, #[tool(aggr)] p: ChannelIdParam) -> String { match parse_uuid(&p.channel_id) { Ok(id) => schedule::generate_schedule(&self.schedule_deps, id).await, Err(e) => e, } } #[tool(description = "Get the currently active schedule for a channel (returns null if none)")] async fn get_active_schedule(&self, #[tool(aggr)] p: ChannelIdParam) -> String { match parse_uuid(&p.channel_id) { Ok(id) => schedule::get_active_schedule(&self.schedule_query, id).await, Err(e) => e, } } #[tool( description = "Get what is currently broadcasting on a channel (returns null if in a gap or no schedule)" )] async fn get_current_broadcast(&self, #[tool(aggr)] p: ChannelIdParam) -> String { match parse_uuid(&p.channel_id) { Ok(id) => schedule::get_current_broadcast(&self.schedule_deps, id).await, Err(e) => e, } } #[tool(description = "List media collections/libraries available in the library")] async fn list_collections(&self) -> String { library::list_collections(&self.library_query).await } #[tool( description = "List genres available in the library, optionally filtered by content type (movie/episode/short)" )] async fn list_genres(&self, #[tool(aggr)] p: ListGenresParams) -> String { library::list_genres(&self.library_query, p.content_type).await } #[tool( description = "Search media items. content_type: movie|episode|short. Returns JSON array of MediaItem." )] async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String { library::search_media( &self.library_command_deps, p.content_type, p.genres.unwrap_or_default(), p.search_term, p.series_names.unwrap_or_default(), p.collections.unwrap_or_default(), ) .await } #[tool( description = "Export a channel's schedule as iCalendar (.ics). Returns RFC 5545 text." )] async fn export_schedule_ical(&self, #[tool(aggr)] p: ChannelIdParam) -> String { match parse_uuid(&p.channel_id) { Ok(id) => ical::export_schedule_ical(&self.channel_query, id).await, Err(e) => e, } } } #[tool(tool_box)] impl ServerHandler for KTvMcpServer { fn get_info(&self) -> ServerInfo { ServerInfo { protocol_version: ProtocolVersion::V_2024_11_05, capabilities: ServerCapabilities::builder().enable_tools().build(), server_info: Implementation { name: SERVER_NAME.into(), version: env!("CARGO_PKG_VERSION").into(), }, instructions: Some( "K-TV MCP server. Create channels, define programming blocks, generate schedules. \ All operations run as the user configured via MCP_USER_ID." .into(), ), } } }