mcp: MCP server calling application use cases
This commit is contained in:
39
crates/mcp/Cargo.toml
Normal file
39
crates/mcp/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "k-tv-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["sqlite", "jellyfin"]
|
||||
sqlite = ["dep:adapter-sqlite", "infra-wiring/sqlite"]
|
||||
postgres = ["dep:adapter-postgres", "infra-wiring/postgres"]
|
||||
jellyfin = ["dep:adapter-jellyfin"]
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
adapter-auth = { workspace = true }
|
||||
adapter-event-publisher = { workspace = true }
|
||||
|
||||
adapter-sqlite = { workspace = true, optional = true }
|
||||
adapter-postgres = { workspace = true, optional = true }
|
||||
adapter-jellyfin = { workspace = true, optional = true }
|
||||
|
||||
rmcp = { version = "0.1", features = ["server", "transport-io"] }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = "1"
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
schemars = "0.8"
|
||||
async-trait = { workspace = true }
|
||||
dotenvy = "0.15"
|
||||
13
crates/mcp/src/error.rs
Normal file
13
crates/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(json_err)
|
||||
}
|
||||
335
crates/mcp/src/main.rs
Normal file
335
crates/mcp/src/main.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol};
|
||||
use domain::{DomainError, DomainResult, MediaFilter, MediaItemId, MediaItem, ScheduleEngineService};
|
||||
use domain::ports::StreamQuality;
|
||||
use infra_wiring::DbPool;
|
||||
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);
|
||||
|
||||
let pool = DbPool::connect(&database_url).await?;
|
||||
pool.run_migrations().await?;
|
||||
|
||||
let wire = wire_repositories(&pool)?;
|
||||
|
||||
let provider_registry = build_provider_registry().await;
|
||||
|
||||
let event_bus = Arc::new(adapter_event_publisher::ChannelEventBus::new(64));
|
||||
let event_publisher: Arc<dyn domain::ports::EventPublisher> = event_bus.clone();
|
||||
|
||||
let schedule_engine = Arc::new(ScheduleEngineService::new(
|
||||
provider_registry.clone(),
|
||||
wire.channel_query.clone(),
|
||||
wire.schedule_query.clone(),
|
||||
wire.schedule_command.clone(),
|
||||
));
|
||||
|
||||
let channel_cmd_deps = Arc::new(application::channels::ChannelCommandDeps {
|
||||
channel_command: wire.channel_command.clone(),
|
||||
channel_query: wire.channel_query.clone(),
|
||||
event_publisher: event_publisher.clone(),
|
||||
});
|
||||
|
||||
let channel_query_deps = Arc::new(application::channels::ChannelQueryDeps {
|
||||
channel_query: wire.channel_query.clone(),
|
||||
});
|
||||
|
||||
let schedule_deps = Arc::new(application::schedule::ScheduleDeps {
|
||||
schedule_engine,
|
||||
channel_query: wire.channel_query.clone(),
|
||||
schedule_query: wire.schedule_query.clone(),
|
||||
schedule_command: wire.schedule_command.clone(),
|
||||
event_publisher: event_publisher.clone(),
|
||||
});
|
||||
|
||||
let library_query_deps = Arc::new(application::library::LibraryQueryDeps {
|
||||
library_query: wire.library_query.clone(),
|
||||
});
|
||||
|
||||
let server = KTvMcpServer {
|
||||
channel_cmd_deps,
|
||||
channel_query_deps,
|
||||
schedule_deps,
|
||||
library_query_deps,
|
||||
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 WireOutput {
|
||||
channel_command: Arc<dyn domain::ports::ChannelCommand>,
|
||||
channel_query: Arc<dyn domain::ports::ChannelQuery>,
|
||||
schedule_command: Arc<dyn domain::ports::ScheduleCommand>,
|
||||
schedule_query: Arc<dyn domain::ports::ScheduleQuery>,
|
||||
library_query: Arc<dyn domain::ports::LibraryQuery>,
|
||||
}
|
||||
|
||||
fn wire_repositories(pool: &DbPool) -> anyhow::Result<WireOutput> {
|
||||
match pool {
|
||||
#[cfg(feature = "sqlite")]
|
||||
DbPool::Sqlite(sqlite_pool) => {
|
||||
let w = adapter_sqlite::wire(sqlite_pool.clone());
|
||||
Ok(WireOutput {
|
||||
channel_command: w.channel_command,
|
||||
channel_query: w.channel_query,
|
||||
schedule_command: w.schedule_command,
|
||||
schedule_query: w.schedule_query,
|
||||
library_query: w.library_query,
|
||||
})
|
||||
}
|
||||
#[cfg(feature = "postgres")]
|
||||
DbPool::Postgres(pg_pool) => {
|
||||
let w = adapter_postgres::wire(pg_pool.clone());
|
||||
Ok(WireOutput {
|
||||
channel_command: w.channel_command,
|
||||
channel_query: w.channel_query,
|
||||
schedule_command: w.schedule_command,
|
||||
schedule_query: w.schedule_query,
|
||||
library_query: w.library_query,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_provider_registry() -> Arc<dyn IProviderRegistry> {
|
||||
let mut providers: Vec<(String, Arc<dyn IMediaProvider>)> = Vec::new();
|
||||
|
||||
#[cfg(feature = "jellyfin")]
|
||||
if let (Some(url), Some(api_key), Some(user_id)) = (
|
||||
std::env::var("JELLYFIN_BASE_URL").ok(),
|
||||
std::env::var("JELLYFIN_API_KEY").ok(),
|
||||
std::env::var("JELLYFIN_USER_ID").ok(),
|
||||
) {
|
||||
info!("Media provider: Jellyfin at {}", url);
|
||||
providers.push((
|
||||
"jellyfin".to_string(),
|
||||
Arc::new(adapter_jellyfin::JellyfinMediaProvider::new(
|
||||
adapter_jellyfin::JellyfinConfig {
|
||||
base_url: url,
|
||||
api_key,
|
||||
user_id,
|
||||
},
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
if providers.is_empty() {
|
||||
tracing::warn!("No media provider configured. Set JELLYFIN_BASE_URL.");
|
||||
providers.push(("noop".to_string(), Arc::new(NoopMediaProvider)));
|
||||
}
|
||||
|
||||
Arc::new(SimpleProviderRegistry::new(providers))
|
||||
}
|
||||
|
||||
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,
|
||||
transcode: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_items(&self, _: &MediaFilter) -> DomainResult<Vec<MediaItem>> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No media provider configured.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_by_id(&self, _: &MediaItemId) -> DomainResult<Option<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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleProviderRegistry {
|
||||
providers: Vec<(String, Arc<dyn IMediaProvider>)>,
|
||||
}
|
||||
|
||||
impl SimpleProviderRegistry {
|
||||
fn new(providers: Vec<(String, Arc<dyn IMediaProvider>)>) -> Self {
|
||||
Self { providers }
|
||||
}
|
||||
|
||||
fn get(&self, id: &str) -> Option<&Arc<dyn IMediaProvider>> {
|
||||
self.providers.iter().find(|(k, _)| k == id).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn primary(&self) -> Option<&Arc<dyn IMediaProvider>> {
|
||||
self.providers.first().map(|(_, v)| v)
|
||||
}
|
||||
|
||||
fn extract_provider_id(item_id: &str) -> Option<&str> {
|
||||
item_id.find("::").map(|pos| &item_id[..pos])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IProviderRegistry for SimpleProviderRegistry {
|
||||
async fn fetch_items(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
filter: &MediaFilter,
|
||||
) -> DomainResult<Vec<MediaItem>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.providers.first().map(|(k, _)| k.as_str()).unwrap_or("")
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.fetch_items(filter).await
|
||||
}
|
||||
|
||||
async fn fetch_by_id(&self, item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
|
||||
let id_str = item_id.value();
|
||||
if let Some(pid) = Self::extract_provider_id(id_str)
|
||||
&& let Some(provider) = self.get(pid)
|
||||
{
|
||||
return provider.fetch_by_id(item_id).await;
|
||||
}
|
||||
if let Some(provider) = self.primary() {
|
||||
provider.fetch_by_id(item_id).await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
item_id: &MediaItemId,
|
||||
quality: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
let id_str = item_id.value();
|
||||
if let Some(pid) = Self::extract_provider_id(id_str)
|
||||
&& let Some(provider) = self.get(pid)
|
||||
{
|
||||
return provider.get_stream_url(item_id, quality).await;
|
||||
}
|
||||
if let Some(provider) = self.primary() {
|
||||
provider.get_stream_url(item_id, quality).await
|
||||
} else {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"No provider available".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_ids(&self) -> Vec<String> {
|
||||
self.providers.iter().map(|(k, _)| k.clone()).collect()
|
||||
}
|
||||
|
||||
fn primary_id(&self) -> &str {
|
||||
self.providers
|
||||
.first()
|
||||
.map(|(k, _)| k.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn capabilities(&self, provider_id: &str) -> Option<ProviderCapabilities> {
|
||||
self.get(provider_id).map(|p| p.capabilities())
|
||||
}
|
||||
|
||||
async fn list_collections(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> DomainResult<Vec<domain::ports::Collection>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.primary_id()
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.list_collections().await
|
||||
}
|
||||
|
||||
async fn list_series(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
collection_id: Option<&str>,
|
||||
) -> DomainResult<Vec<domain::ports::SeriesSummary>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.primary_id()
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.list_series(collection_id).await
|
||||
}
|
||||
|
||||
async fn list_genres(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
content_type: Option<&domain::ContentType>,
|
||||
) -> DomainResult<Vec<String>> {
|
||||
let id = if provider_id.is_empty() {
|
||||
self.primary_id()
|
||||
} else {
|
||||
provider_id
|
||||
};
|
||||
let provider = self
|
||||
.get(id)
|
||||
.ok_or_else(|| DomainError::InfrastructureError(format!("Unknown provider: {id}")))?;
|
||||
provider.list_genres(content_type).await
|
||||
}
|
||||
}
|
||||
204
crates/mcp/src/server.rs
Normal file
204
crates/mcp/src/server.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{
|
||||
channels::{ChannelCommandDeps, ChannelQueryDeps},
|
||||
library::LibraryQueryDeps,
|
||||
schedule::ScheduleDeps,
|
||||
};
|
||||
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_cmd_deps: Arc<ChannelCommandDeps>,
|
||||
pub channel_query_deps: Arc<ChannelQueryDeps>,
|
||||
pub schedule_deps: Arc<ScheduleDeps>,
|
||||
pub library_query_deps: Arc<LibraryQueryDeps>,
|
||||
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<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub schedule_config_json: Option<String>,
|
||||
}
|
||||
|
||||
#[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<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 {
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
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_query_deps, 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_deps, 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, and/or schedule config")]
|
||||
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,
|
||||
};
|
||||
channels::update_channel(
|
||||
&self.channel_cmd_deps,
|
||||
id,
|
||||
self.owner_id,
|
||||
p.name,
|
||||
p.timezone,
|
||||
p.description,
|
||||
schedule_config,
|
||||
)
|
||||
.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_deps, 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_deps).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_deps, p.content_type).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Search media items. content_type: movie|episode|short. Returns JSON array of LibraryItem."
|
||||
)]
|
||||
async fn search_media(&self, #[tool(aggr)] p: SearchMediaParams) -> String {
|
||||
library::search_media(
|
||||
&self.library_query_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(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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
86
crates/mcp/src/tools/channels.rs
Normal file
86
crates/mcp/src/tools/channels.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::channels::{
|
||||
ChannelCommandDeps, ChannelQueryDeps, CreateChannelCommand, DeleteChannelCommand,
|
||||
GetChannelQuery, ListByOwnerQuery, UpdateChannelCommand,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{domain_err, ok_json};
|
||||
|
||||
pub async fn list_channels(
|
||||
query_deps: &Arc<ChannelQueryDeps>,
|
||||
owner_id: Uuid,
|
||||
) -> String {
|
||||
let query = ListByOwnerQuery { owner_id };
|
||||
match application::channels::list_by_owner::execute(query_deps, query).await {
|
||||
Ok(channels) => ok_json(&channels),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_channel(query_deps: &Arc<ChannelQueryDeps>, id: Uuid) -> String {
|
||||
let query = GetChannelQuery { channel_id: id };
|
||||
match application::channels::get::execute(query_deps, query).await {
|
||||
Ok(Some(channel)) => ok_json(&channel),
|
||||
Ok(None) => serde_json::json!({"error": "Channel not found"}).to_string(),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_channel(
|
||||
cmd_deps: &Arc<ChannelCommandDeps>,
|
||||
owner_id: Uuid,
|
||||
name: &str,
|
||||
timezone: &str,
|
||||
) -> String {
|
||||
let cmd = CreateChannelCommand {
|
||||
owner_id,
|
||||
name: name.to_string(),
|
||||
timezone: timezone.to_string(),
|
||||
};
|
||||
match application::channels::create::execute(cmd_deps, cmd).await {
|
||||
Ok(channel) => ok_json(&channel),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_channel(
|
||||
cmd_deps: &Arc<ChannelCommandDeps>,
|
||||
channel_id: Uuid,
|
||||
owner_id: Uuid,
|
||||
name: Option<String>,
|
||||
timezone: Option<String>,
|
||||
description: Option<String>,
|
||||
schedule_config: Option<domain::ScheduleConfig>,
|
||||
) -> String {
|
||||
let cmd = UpdateChannelCommand {
|
||||
channel_id,
|
||||
owner_id,
|
||||
name,
|
||||
description: description.map(Some),
|
||||
timezone,
|
||||
schedule_config,
|
||||
recycle_policy: None,
|
||||
auto_schedule: None,
|
||||
};
|
||||
match application::channels::update::execute(cmd_deps, cmd).await {
|
||||
Ok(channel) => ok_json(&channel),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_channel(
|
||||
cmd_deps: &Arc<ChannelCommandDeps>,
|
||||
channel_id: Uuid,
|
||||
owner_id: Uuid,
|
||||
) -> String {
|
||||
let cmd = DeleteChannelCommand {
|
||||
channel_id,
|
||||
owner_id,
|
||||
};
|
||||
match application::channels::delete::execute(cmd_deps, cmd).await {
|
||||
Ok(()) => serde_json::json!({"deleted": channel_id}).to_string(),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
123
crates/mcp/src/tools/library.rs
Normal file
123
crates/mcp/src/tools/library.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::library::{
|
||||
LibraryQueryDeps, ListCollectionsQuery, ListGenresQuery, SearchItemsQuery,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::{domain_err, ok_json};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CollectionDto {
|
||||
id: String,
|
||||
name: String,
|
||||
collection_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LibraryItemDto {
|
||||
id: String,
|
||||
provider_id: String,
|
||||
external_id: String,
|
||||
title: String,
|
||||
content_type: String,
|
||||
duration_secs: u32,
|
||||
series_name: Option<String>,
|
||||
season_number: Option<u32>,
|
||||
episode_number: Option<u32>,
|
||||
year: Option<u16>,
|
||||
genres: Vec<String>,
|
||||
tags: Vec<String>,
|
||||
collection_id: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SearchResult {
|
||||
items: Vec<LibraryItemDto>,
|
||||
total: u32,
|
||||
}
|
||||
|
||||
fn to_content_type_string(ct: &domain::ContentType) -> String {
|
||||
match ct {
|
||||
domain::ContentType::Movie => "movie".to_string(),
|
||||
domain::ContentType::Episode => "episode".to_string(),
|
||||
domain::ContentType::Short => "short".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_collections(deps: &Arc<LibraryQueryDeps>) -> String {
|
||||
let query = ListCollectionsQuery { provider_id: None };
|
||||
match application::library::list_collections::execute(deps, query).await {
|
||||
Ok(cols) => {
|
||||
let dtos: Vec<CollectionDto> = cols
|
||||
.into_iter()
|
||||
.map(|c| CollectionDto {
|
||||
id: c.id().to_string(),
|
||||
name: c.name().to_string(),
|
||||
collection_type: c.collection_type().map(|s| s.to_string()),
|
||||
})
|
||||
.collect();
|
||||
ok_json(&dtos)
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_genres(deps: &Arc<LibraryQueryDeps>, content_type: Option<String>) -> String {
|
||||
let query = ListGenresQuery {
|
||||
content_type,
|
||||
provider_id: None,
|
||||
};
|
||||
match application::library::list_genres::execute(deps, query).await {
|
||||
Ok(genres) => ok_json(&genres),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_media(
|
||||
deps: &Arc<LibraryQueryDeps>,
|
||||
content_type: Option<String>,
|
||||
genres: Vec<String>,
|
||||
search_term: Option<String>,
|
||||
series_names: Vec<String>,
|
||||
collections: Vec<String>,
|
||||
) -> String {
|
||||
let query = SearchItemsQuery {
|
||||
provider_id: None,
|
||||
content_type,
|
||||
genres,
|
||||
search_term,
|
||||
series_names,
|
||||
collection_id: collections.first().cloned(),
|
||||
season_number: None,
|
||||
decade: None,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
};
|
||||
match application::library::search::execute(deps, query).await {
|
||||
Ok((items, total)) => {
|
||||
let dtos: Vec<LibraryItemDto> = items
|
||||
.into_iter()
|
||||
.map(|i| LibraryItemDto {
|
||||
id: i.id().to_string(),
|
||||
provider_id: i.provider_id().to_string(),
|
||||
external_id: i.external_id().to_string(),
|
||||
title: i.title().to_string(),
|
||||
content_type: to_content_type_string(i.content_type()),
|
||||
duration_secs: i.duration_secs(),
|
||||
series_name: i.series_name().map(|s| s.to_string()),
|
||||
season_number: i.season_number(),
|
||||
episode_number: i.episode_number(),
|
||||
year: i.year(),
|
||||
genres: i.genres().to_vec(),
|
||||
tags: i.tags().to_vec(),
|
||||
collection_id: i.collection_id().map(|s| s.to_string()),
|
||||
thumbnail_url: i.thumbnail_url().map(|s| s.to_string()),
|
||||
})
|
||||
.collect();
|
||||
ok_json(&SearchResult { items: dtos, total })
|
||||
}
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
3
crates/mcp/src/tools/mod.rs
Normal file
3
crates/mcp/src/tools/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod channels;
|
||||
pub mod library;
|
||||
pub mod schedule;
|
||||
48
crates/mcp/src/tools/schedule.rs
Normal file
48
crates/mcp/src/tools/schedule.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::schedule::{
|
||||
GenerateScheduleCommand, GetActiveScheduleQuery, GetCurrentBroadcastQuery, ScheduleDeps,
|
||||
};
|
||||
use domain::ScheduledSlot;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{domain_err, ok_json};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CurrentBroadcastDto {
|
||||
slot: ScheduledSlot,
|
||||
offset_secs: u32,
|
||||
}
|
||||
|
||||
pub async fn generate_schedule(deps: &Arc<ScheduleDeps>, channel_id: Uuid) -> String {
|
||||
let cmd = GenerateScheduleCommand { channel_id };
|
||||
match application::schedule::generate::execute(deps, cmd).await {
|
||||
Ok(schedule) => ok_json(&schedule),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_active_schedule(deps: &Arc<ScheduleDeps>, channel_id: Uuid) -> String {
|
||||
let query = GetActiveScheduleQuery { channel_id };
|
||||
match application::schedule::get_active::execute(deps, query).await {
|
||||
Ok(Some(schedule)) => ok_json(&schedule),
|
||||
Ok(None) => "null".to_string(),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_current_broadcast(deps: &Arc<ScheduleDeps>, channel_id: Uuid) -> String {
|
||||
let query = GetCurrentBroadcastQuery { channel_id };
|
||||
match application::schedule::get_current_broadcast::execute(deps, query).await {
|
||||
Ok(Some(b)) => {
|
||||
let offset = b.offset_secs();
|
||||
ok_json(&CurrentBroadcastDto {
|
||||
slot: b.into_slot(),
|
||||
offset_secs: offset,
|
||||
})
|
||||
}
|
||||
Ok(None) => "null".to_string(),
|
||||
Err(e) => domain_err(e),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user