feat(mcp): implement media channel management and scheduling features
This commit is contained in:
111
k-tv-backend/Cargo.lock
generated
111
k-tv-backend/Cargo.lock
generated
@@ -850,6 +850,21 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
@@ -923,6 +938,7 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1581,6 +1597,28 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"infra",
|
||||
"k-core",
|
||||
"rmcp",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -1918,6 +1956,12 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -2344,6 +2388,38 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"futures",
|
||||
"paste",
|
||||
"pin-project-lite",
|
||||
"rmcp-macros",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.9"
|
||||
@@ -2489,6 +2565,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
@@ -2513,6 +2601,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars_derive"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -2602,6 +2702,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive_internals"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.148"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
members = ["domain", "infra", "api"]
|
||||
members = ["domain", "infra", "api", "mcp"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -11,7 +11,11 @@ FROM debian:bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning)
|
||||
RUN apt-get update && apt-get install -y libssl3 ca-certificates ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/api .
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ auth-jwt = ["infra/auth-jwt"]
|
||||
jellyfin = ["infra/jellyfin"]
|
||||
local-files = ["infra/local-files", "dep:tokio-util"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[dependencies]
|
||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||
"logging",
|
||||
@@ -25,7 +30,10 @@ infra = { path = "../infra", default-features = false, features = ["sqlite"] }
|
||||
|
||||
# Web framework
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
|
||||
axum-extra = { version = "0.10", features = [
|
||||
"cookie-private",
|
||||
"cookie-key-expansion",
|
||||
] }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
|
||||
|
||||
|
||||
36
k-tv-backend/mcp/Cargo.toml
Normal file
36
k-tv-backend/mcp/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
default-run = "mcp"
|
||||
|
||||
[features]
|
||||
default = ["sqlite", "jellyfin"]
|
||||
sqlite = ["infra/sqlite"]
|
||||
postgres = ["infra/postgres"]
|
||||
jellyfin = ["infra/jellyfin"]
|
||||
local-files = ["infra/local-files"]
|
||||
|
||||
[dependencies]
|
||||
domain = { path = "../domain" }
|
||||
infra = { path = "../infra", default-features = false, features = ["sqlite"] }
|
||||
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
|
||||
"logging",
|
||||
"db-sqlx",
|
||||
"sqlite",
|
||||
] }
|
||||
|
||||
rmcp = { version = "0.1", features = ["server", "transport-io"] }
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
schemars = "0.8"
|
||||
dotenvy = "0.15"
|
||||
async-trait = "0.1"
|
||||
13
k-tv-backend/mcp/src/error.rs
Normal file
13
k-tv-backend/mcp/src/error.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use domain::DomainError;
|
||||
|
||||
pub fn domain_err(e: DomainError) -> String {
|
||||
serde_json::json!({"error": e.to_string()}).to_string()
|
||||
}
|
||||
|
||||
pub fn json_err(e: serde_json::Error) -> String {
|
||||
serde_json::json!({"error": format!("serialization failed: {e}")}).to_string()
|
||||
}
|
||||
|
||||
pub fn ok_json<T: serde::Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_else(|e| json_err(e))
|
||||
}
|
||||
178
k-tv-backend/mcp/src/main.rs
Normal file
178
k-tv-backend/mcp/src/main.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use domain::{
|
||||
ChannelService, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItemId,
|
||||
ProviderCapabilities, ScheduleEngineService, StreamQuality, StreamingProtocol, UserService,
|
||||
};
|
||||
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
|
||||
use infra::run_migrations;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod error;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
use server::KTvMcpServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("mcp=info".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string());
|
||||
|
||||
let owner_id: Uuid = std::env::var("MCP_USER_ID")
|
||||
.map_err(|_| anyhow::anyhow!("MCP_USER_ID env var is required (UUID of the user)"))?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("MCP_USER_ID must be a valid UUID"))?;
|
||||
|
||||
info!("Connecting to database: {}", database_url);
|
||||
|
||||
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
|
||||
let db_type = k_core::db::DbType::Sqlite;
|
||||
|
||||
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
|
||||
let db_type = k_core::db::DbType::Postgres;
|
||||
|
||||
#[cfg(all(feature = "sqlite", feature = "postgres"))]
|
||||
let db_type = if database_url.starts_with("postgres") {
|
||||
k_core::db::DbType::Postgres
|
||||
} else {
|
||||
k_core::db::DbType::Sqlite
|
||||
};
|
||||
|
||||
let db_config = k_core::db::DatabaseConfig {
|
||||
db_type,
|
||||
url: database_url.clone(),
|
||||
max_connections: 5,
|
||||
min_connections: 1,
|
||||
acquire_timeout: StdDuration::from_secs(30),
|
||||
};
|
||||
|
||||
let db_pool = k_core::db::connect(&db_config).await?;
|
||||
run_migrations(&db_pool).await?;
|
||||
|
||||
let user_repo = build_user_repository(&db_pool).await?;
|
||||
let channel_repo = build_channel_repository(&db_pool).await?;
|
||||
let schedule_repo = build_schedule_repository(&db_pool).await?;
|
||||
|
||||
let _user_service = UserService::new(user_repo);
|
||||
let channel_service = ChannelService::new(channel_repo.clone());
|
||||
|
||||
let mut maybe_provider: Option<Arc<dyn IMediaProvider>> = None;
|
||||
|
||||
#[cfg(feature = "jellyfin")]
|
||||
{
|
||||
let base_url = std::env::var("JELLYFIN_BASE_URL").ok();
|
||||
let api_key = std::env::var("JELLYFIN_API_KEY").ok();
|
||||
let user_id = std::env::var("JELLYFIN_USER_ID").ok();
|
||||
if let (Some(base_url), Some(api_key), Some(user_id)) = (base_url, api_key, user_id) {
|
||||
info!("Media provider: Jellyfin at {}", base_url);
|
||||
maybe_provider = Some(Arc::new(infra::JellyfinMediaProvider::new(
|
||||
infra::JellyfinConfig {
|
||||
base_url,
|
||||
api_key,
|
||||
user_id,
|
||||
},
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local-files")]
|
||||
if maybe_provider.is_none() {
|
||||
if let Some(dir) = std::env::var("LOCAL_FILES_DIR").ok().map(std::path::PathBuf::from) {
|
||||
if let k_core::db::DatabasePool::Sqlite(ref sqlite_pool) = db_pool {
|
||||
let base_url = std::env::var("BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let lf_cfg = infra::LocalFilesConfig {
|
||||
root_dir: dir,
|
||||
base_url,
|
||||
};
|
||||
let idx = Arc::new(infra::LocalIndex::new(&lf_cfg, sqlite_pool.clone()).await);
|
||||
let scan_idx = Arc::clone(&idx);
|
||||
tokio::spawn(async move { scan_idx.rescan().await; });
|
||||
maybe_provider = Some(Arc::new(infra::LocalFilesProvider::new(idx, lf_cfg)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let media_provider: Arc<dyn IMediaProvider> = maybe_provider.unwrap_or_else(|| {
|
||||
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL or LOCAL_FILES_DIR.");
|
||||
Arc::new(NoopMediaProvider)
|
||||
});
|
||||
|
||||
let schedule_engine = ScheduleEngineService::new(
|
||||
Arc::clone(&media_provider),
|
||||
channel_repo,
|
||||
schedule_repo,
|
||||
);
|
||||
|
||||
let server = KTvMcpServer {
|
||||
channel_service: Arc::new(channel_service),
|
||||
schedule_engine: Arc::new(schedule_engine),
|
||||
media_provider,
|
||||
owner_id,
|
||||
};
|
||||
|
||||
info!("K-TV MCP server starting (stdio transport), owner_id={}", owner_id);
|
||||
|
||||
use rmcp::ServiceExt;
|
||||
let service = server
|
||||
.serve(rmcp::transport::stdio())
|
||||
.await
|
||||
.inspect_err(|e| tracing::error!("MCP server error: {e}"))?;
|
||||
|
||||
service.waiting().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct NoopMediaProvider;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IMediaProvider for NoopMediaProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
collections: false,
|
||||
series: false,
|
||||
genres: false,
|
||||
tags: false,
|
||||
decade: false,
|
||||
search: false,
|
||||
streaming_protocol: StreamingProtocol::DirectFile,
|
||||
rescan: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_items(&self, _: &MediaFilter) -> DomainResult<Vec<domain::MediaItem>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_by_id(&self, _: &MediaItemId) -> DomainResult<Option<domain::MediaItem>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_: &MediaItemId,
|
||||
_: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
305
k-tv-backend/mcp/src/server.rs
Normal file
305
k-tv-backend/mcp/src/server.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
ChannelService, ContentType, IMediaProvider, 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 media_provider: Arc<dyn IMediaProvider>,
|
||||
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 array of ProgrammingBlock objects
|
||||
pub blocks_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct AddBlockParams {
|
||||
/// Channel UUID
|
||||
pub channel_id: 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. blocks_json is a JSON array of ProgrammingBlock objects."
|
||||
)]
|
||||
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 blocks: Vec<ProgrammingBlock> = match serde_json::from_str(&p.blocks_json) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return serde_json::json!({"error": format!("invalid blocks_json: {e}")})
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
channels::set_schedule_config(
|
||||
&self.channel_service,
|
||||
channel_id,
|
||||
ScheduleConfig { blocks },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Append a ProgrammingBlock to a channel's schedule. 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 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, 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.media_provider).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.media_provider, 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.media_provider,
|
||||
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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
127
k-tv-backend/mcp/src/tools/channels.rs
Normal file
127
k-tv-backend/mcp/src/tools/channels.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use domain::{Channel, ChannelService, ScheduleConfig, UserId};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{domain_err, ok_json};
|
||||
|
||||
pub async fn list_channels(svc: &Arc<ChannelService>, owner_id: UserId) -> String {
|
||||
match svc.find_by_owner(owner_id).await {
|
||||
Ok(channels) => ok_json(&channels),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_channel(svc: &Arc<ChannelService>, id: Uuid) -> String {
|
||||
match svc.find_by_id(id).await {
|
||||
Ok(channel) => ok_json(&channel),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_channel(
|
||||
svc: &Arc<ChannelService>,
|
||||
owner_id: UserId,
|
||||
name: &str,
|
||||
timezone: &str,
|
||||
description: Option<String>,
|
||||
) -> String {
|
||||
let channel = match svc.create(owner_id, name, timezone).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return domain_err(e),
|
||||
};
|
||||
if description.is_none() {
|
||||
return ok_json(&channel);
|
||||
}
|
||||
let mut channel: Channel = channel;
|
||||
channel.description = description;
|
||||
channel.updated_at = chrono::Utc::now();
|
||||
match svc.update(channel).await {
|
||||
Ok(c) => ok_json(&c),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_channel(
|
||||
svc: &Arc<ChannelService>,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
timezone: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> String {
|
||||
let mut channel: Channel = match svc.find_by_id(id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return domain_err(e),
|
||||
};
|
||||
if let Some(n) = name {
|
||||
channel.name = n;
|
||||
}
|
||||
if let Some(tz) = timezone {
|
||||
channel.timezone = tz;
|
||||
}
|
||||
if description.is_some() {
|
||||
channel.description = description;
|
||||
}
|
||||
channel.updated_at = chrono::Utc::now();
|
||||
match svc.update(channel).await {
|
||||
Ok(c) => ok_json(&c),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_channel(svc: &Arc<ChannelService>, id: Uuid, owner_id: UserId) -> String {
|
||||
match svc.delete(id, owner_id).await {
|
||||
Ok(()) => serde_json::json!({"deleted": id}).to_string(),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_schedule_config(
|
||||
svc: &Arc<ChannelService>,
|
||||
channel_id: Uuid,
|
||||
config: ScheduleConfig,
|
||||
) -> String {
|
||||
let mut channel: Channel = match svc.find_by_id(channel_id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return domain_err(e),
|
||||
};
|
||||
channel.schedule_config = config;
|
||||
channel.updated_at = chrono::Utc::now();
|
||||
match svc.update(channel).await {
|
||||
Ok(c) => ok_json(&c),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_programming_block(
|
||||
svc: &Arc<ChannelService>,
|
||||
channel_id: Uuid,
|
||||
block: domain::ProgrammingBlock,
|
||||
) -> String {
|
||||
let mut channel: Channel = match svc.find_by_id(channel_id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return domain_err(e),
|
||||
};
|
||||
channel.schedule_config.blocks.push(block);
|
||||
channel.updated_at = chrono::Utc::now();
|
||||
match svc.update(channel).await {
|
||||
Ok(c) => ok_json(&c),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_programming_block(
|
||||
svc: &Arc<ChannelService>,
|
||||
channel_id: Uuid,
|
||||
block_id: Uuid,
|
||||
) -> String {
|
||||
let mut channel: Channel = match svc.find_by_id(channel_id).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return domain_err(e),
|
||||
};
|
||||
channel.schedule_config.blocks.retain(|b| b.id != block_id);
|
||||
channel.updated_at = chrono::Utc::now();
|
||||
match svc.update(channel).await {
|
||||
Ok(c) => ok_json(&c),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
43
k-tv-backend/mcp/src/tools/library.rs
Normal file
43
k-tv-backend/mcp/src/tools/library.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use domain::{ContentType, IMediaProvider, MediaFilter};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::{domain_err, ok_json};
|
||||
|
||||
pub async fn list_collections(provider: &Arc<dyn IMediaProvider>) -> String {
|
||||
match provider.list_collections().await {
|
||||
Ok(cols) => ok_json(&cols),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_genres(
|
||||
provider: &Arc<dyn IMediaProvider>,
|
||||
content_type: Option<ContentType>,
|
||||
) -> String {
|
||||
match provider.list_genres(content_type.as_ref()).await {
|
||||
Ok(genres) => ok_json(&genres),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_media(
|
||||
provider: &Arc<dyn IMediaProvider>,
|
||||
content_type: Option<ContentType>,
|
||||
genres: Vec<String>,
|
||||
search_term: Option<String>,
|
||||
series_names: Vec<String>,
|
||||
collections: Vec<String>,
|
||||
) -> String {
|
||||
let filter = MediaFilter {
|
||||
content_type,
|
||||
genres,
|
||||
search_term,
|
||||
series_names,
|
||||
collections,
|
||||
..Default::default()
|
||||
};
|
||||
match provider.fetch_items(&filter).await {
|
||||
Ok(items) => ok_json(&items),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
3
k-tv-backend/mcp/src/tools/mod.rs
Normal file
3
k-tv-backend/mcp/src/tools/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod channels;
|
||||
pub mod library;
|
||||
pub mod schedule;
|
||||
47
k-tv-backend/mcp/src/tools/schedule.rs
Normal file
47
k-tv-backend/mcp/src/tools/schedule.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use chrono::Utc;
|
||||
use domain::{ScheduleEngineService, ScheduledSlot};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{domain_err, ok_json};
|
||||
|
||||
/// Serializable DTO for CurrentBroadcast (domain type does not derive Serialize).
|
||||
#[derive(Serialize)]
|
||||
struct CurrentBroadcastDto {
|
||||
slot: ScheduledSlot,
|
||||
offset_secs: u32,
|
||||
}
|
||||
|
||||
pub async fn generate_schedule(engine: &Arc<ScheduleEngineService>, channel_id: Uuid) -> String {
|
||||
match engine.generate_schedule(channel_id, Utc::now()).await {
|
||||
Ok(schedule) => ok_json(&schedule),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_active_schedule(engine: &Arc<ScheduleEngineService>, channel_id: Uuid) -> String {
|
||||
match engine.get_active_schedule(channel_id, Utc::now()).await {
|
||||
Ok(Some(schedule)) => ok_json(&schedule),
|
||||
Ok(None) => "null".to_string(),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_current_broadcast(
|
||||
engine: &Arc<ScheduleEngineService>,
|
||||
channel_id: Uuid,
|
||||
) -> String {
|
||||
let schedule = match engine.get_active_schedule(channel_id, Utc::now()).await {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return "null".to_string(),
|
||||
Err(e) => return domain_err(e),
|
||||
};
|
||||
match ScheduleEngineService::get_current_broadcast(&schedule, Utc::now()) {
|
||||
Some(b) => ok_json(&CurrentBroadcastDto {
|
||||
slot: b.slot,
|
||||
offset_secs: b.offset_secs,
|
||||
}),
|
||||
None => "null".to_string(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user