308 lines
10 KiB
Rust
308 lines
10 KiB
Rust
use std::sync::Arc;
|
|
|
|
use domain::{
|
|
ChannelService, ContentType, ProgrammingBlock, ScheduleConfig, ScheduleEngineService,
|
|
};
|
|
use rmcp::{
|
|
ServerHandler,
|
|
model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo},
|
|
tool,
|
|
};
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
use crate::tools::{channels, library, schedule};
|
|
|
|
#[derive(Clone)]
|
|
pub struct KTvMcpServer {
|
|
pub channel_service: Arc<ChannelService>,
|
|
pub schedule_engine: Arc<ScheduleEngineService>,
|
|
pub provider_registry: Arc<infra::ProviderRegistry>,
|
|
pub owner_id: Uuid,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Parameter types — Uuid fields stored as String to satisfy JsonSchema bound.
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct GetChannelParams {
|
|
/// Channel UUID (e.g. "550e8400-e29b-41d4-a716-446655440000")
|
|
pub id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct CreateChannelParams {
|
|
pub name: String,
|
|
/// IANA timezone, e.g. "America/New_York"
|
|
pub timezone: String,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct UpdateChannelParams {
|
|
/// Channel UUID
|
|
pub id: String,
|
|
pub name: Option<String>,
|
|
pub timezone: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct DeleteChannelParams {
|
|
/// Channel UUID
|
|
pub id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct SetScheduleConfigParams {
|
|
/// Channel UUID
|
|
pub channel_id: String,
|
|
/// JSON object of the full ScheduleConfig shape: {"monday": [...], "tuesday": [...], ...}
|
|
pub day_blocks_json: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct AddBlockParams {
|
|
/// Channel UUID
|
|
pub channel_id: String,
|
|
/// Day of week: "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"
|
|
pub day: String,
|
|
/// ProgrammingBlock serialized as JSON
|
|
pub block_json: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct RemoveBlockParams {
|
|
/// Channel UUID
|
|
pub channel_id: String,
|
|
/// Block UUID
|
|
pub block_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct ChannelIdParam {
|
|
/// Channel UUID
|
|
pub channel_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct SearchMediaParams {
|
|
/// "movie", "episode", or "short"
|
|
pub content_type: Option<String>,
|
|
pub genres: Option<Vec<String>>,
|
|
pub search_term: Option<String>,
|
|
pub series_names: Option<Vec<String>>,
|
|
pub collections: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, JsonSchema)]
|
|
pub struct ListGenresParams {
|
|
/// Optional content type: "movie", "episode", or "short"
|
|
pub content_type: Option<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tool implementations
|
|
// ============================================================================
|
|
|
|
fn parse_uuid(s: &str) -> Result<Uuid, String> {
|
|
s.parse::<Uuid>()
|
|
.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_service, 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_service, 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_service,
|
|
self.owner_id,
|
|
&p.name,
|
|
&p.timezone,
|
|
p.description,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tool(description = "Update channel name, timezone, and/or description")]
|
|
async fn update_channel(&self, #[tool(aggr)] p: UpdateChannelParams) -> String {
|
|
match parse_uuid(&p.id) {
|
|
Ok(id) => {
|
|
channels::update_channel(
|
|
&self.channel_service,
|
|
id,
|
|
p.name,
|
|
p.timezone,
|
|
p.description,
|
|
)
|
|
.await
|
|
}
|
|
Err(e) => e,
|
|
}
|
|
}
|
|
|
|
#[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_service, id, self.owner_id).await,
|
|
Err(e) => e,
|
|
}
|
|
}
|
|
|
|
#[tool(
|
|
description = "Replace a channel's entire schedule config. day_blocks_json is a JSON object of the ScheduleConfig shape: {\"monday\": [...], ...}"
|
|
)]
|
|
async fn set_schedule_config(&self, #[tool(aggr)] p: SetScheduleConfigParams) -> String {
|
|
let channel_id = match parse_uuid(&p.channel_id) {
|
|
Ok(id) => id,
|
|
Err(e) => return e,
|
|
};
|
|
let config: ScheduleConfig = match serde_json::from_str(&p.day_blocks_json) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
return serde_json::json!({"error": format!("invalid day_blocks_json: {e}")})
|
|
.to_string()
|
|
}
|
|
};
|
|
channels::set_schedule_config(&self.channel_service, channel_id, config).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "Append a ProgrammingBlock to a channel's schedule for a specific day. day: monday|tuesday|wednesday|thursday|friday|saturday|sunday. block_json is a serialized ProgrammingBlock."
|
|
)]
|
|
async fn add_programming_block(&self, #[tool(aggr)] p: AddBlockParams) -> String {
|
|
let channel_id = match parse_uuid(&p.channel_id) {
|
|
Ok(id) => id,
|
|
Err(e) => return e,
|
|
};
|
|
let day: domain::Weekday = match serde_json::from_str(&format!("\"{}\"", p.day)) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
return serde_json::json!({"error": format!("invalid day: {e}")}).to_string()
|
|
}
|
|
};
|
|
let block: ProgrammingBlock = match serde_json::from_str(&p.block_json) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
return serde_json::json!({"error": format!("invalid block_json: {e}")}).to_string()
|
|
}
|
|
};
|
|
channels::add_programming_block(&self.channel_service, channel_id, day, block).await
|
|
}
|
|
|
|
#[tool(description = "Remove a programming block from a channel's schedule by block UUID")]
|
|
async fn remove_programming_block(&self, #[tool(aggr)] p: RemoveBlockParams) -> String {
|
|
let channel_id = match parse_uuid(&p.channel_id) {
|
|
Ok(id) => id,
|
|
Err(e) => return e,
|
|
};
|
|
let block_id = match parse_uuid(&p.block_id) {
|
|
Ok(id) => id,
|
|
Err(e) => return e,
|
|
};
|
|
channels::remove_programming_block(&self.channel_service, channel_id, block_id).await
|
|
}
|
|
|
|
#[tool(description = "Generate a fresh 48-hour 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_engine, 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_engine, 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_engine, id).await,
|
|
Err(e) => e,
|
|
}
|
|
}
|
|
|
|
#[tool(description = "List media collections/libraries available in the configured provider")]
|
|
async fn list_collections(&self) -> String {
|
|
library::list_collections(&self.provider_registry).await
|
|
}
|
|
|
|
#[tool(
|
|
description = "List genres available in the provider, optionally filtered by content type (movie/episode/short)"
|
|
)]
|
|
async fn list_genres(&self, #[tool(aggr)] p: ListGenresParams) -> String {
|
|
let ct = p.content_type.as_deref().and_then(parse_content_type);
|
|
library::list_genres(&self.provider_registry, ct).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 {
|
|
let ct = p.content_type.as_deref().and_then(parse_content_type);
|
|
library::search_media(
|
|
&self.provider_registry,
|
|
ct,
|
|
p.genres.unwrap_or_default(),
|
|
p.search_term,
|
|
p.series_names.unwrap_or_default(),
|
|
p.collections.unwrap_or_default(),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
fn parse_content_type(s: &str) -> Option<ContentType> {
|
|
match s {
|
|
"movie" => Some(ContentType::Movie),
|
|
"episode" => Some(ContentType::Episode),
|
|
"short" => Some(ContentType::Short),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// ServerHandler
|
|
// ============================================================================
|
|
|
|
#[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: "k-tv-mcp".into(),
|
|
version: "0.1.0".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(),
|
|
),
|
|
}
|
|
}
|
|
}
|