#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { XMLParser } from "fast-xml-parser";
const XMLTV_URL = process.env.XMLTV_URL || "http://tunarr.lan/api/xmltv.xml";
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
interface Channel {
id: string;
"display-name": string;
icon?: { src: string };
}
interface Programme {
channel: string;
start: string;
stop: string;
title: string;
"sub-title"?: string;
desc?: string;
"episode-num"?: string | string[];
rating?: { value: string } | { value: string }[];
date?: string;
icon?: { src: string } | { src: string }[];
image?: { src: string } | { src: string }[];
}
interface XmltvData {
tv: {
channel: Channel[];
programme: Programme[];
};
}
interface CacheEntry {
data: XmltvData;
timestamp: number;
}
let cache: CacheEntry | null = null;
/**
* Parse XMLTV datetime format (YYYYMMDDHHMMSS +0000) to Date object
*/
function parseXmltvDate(dateStr: string): Date {
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6)) - 1;
const day = parseInt(dateStr.substring(6, 8));
const hour = parseInt(dateStr.substring(8, 10));
const minute = parseInt(dateStr.substring(10, 12));
const second = parseInt(dateStr.substring(12, 14));
return new Date(Date.UTC(year, month, day, hour, minute, second));
}
/**
* Fetch and parse XMLTV data with caching
*/
async function getXmltvData(): Promise<XmltvData> {
const now = Date.now();
if (cache && (now - cache.timestamp) < CACHE_TTL) {
return cache.data;
}
const response = await fetch(XMLTV_URL);
if (!response.ok) {
throw new Error(`Failed to fetch XMLTV: ${response.statusText}`);
}
const xmlText = await response.text();
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
});
const parsed = parser.parse(xmlText) as XmltvData;
// Ensure arrays are always arrays (parser might return single object)
if (parsed.tv.channel && !Array.isArray(parsed.tv.channel)) {
parsed.tv.channel = [parsed.tv.channel];
}
if (parsed.tv.programme && !Array.isArray(parsed.tv.programme)) {
parsed.tv.programme = [parsed.tv.programme];
}
cache = { data: parsed, timestamp: now };
return parsed;
}
/**
* Get all channels
*/
async function getChannels() {
const data = await getXmltvData();
return data.tv.channel.map(ch => ({
id: ch.id,
name: ch["display-name"],
icon: typeof ch.icon === 'object' ? ch.icon.src : undefined,
}));
}
/**
* Get currently playing programmes
*/
async function getNowPlaying() {
const data = await getXmltvData();
const now = new Date();
const nowPlaying = data.tv.channel.map(channel => {
const currentProgramme = data.tv.programme.find(prog => {
if (prog.channel !== channel.id) return false;
const start = parseXmltvDate(prog.start);
const stop = parseXmltvDate(prog.stop);
return now >= start && now < stop;
});
return {
channel: {
id: channel.id,
name: channel["display-name"],
},
programme: currentProgramme ? {
title: currentProgramme.title,
subtitle: currentProgramme["sub-title"],
description: currentProgramme.desc,
start: currentProgramme.start,
stop: currentProgramme.stop,
episodeNum: Array.isArray(currentProgramme["episode-num"])
? currentProgramme["episode-num"][0]
: currentProgramme["episode-num"],
} : null,
};
});
return nowPlaying;
}
/**
* Get schedule for a specific channel
*/
async function getSchedule(channelId: string, hoursAhead: number = 24) {
const data = await getXmltvData();
const now = new Date();
const endTime = new Date(now.getTime() + hoursAhead * 60 * 60 * 1000);
const schedule = data.tv.programme
.filter(prog => {
if (prog.channel !== channelId) return false;
const start = parseXmltvDate(prog.start);
return start >= now && start <= endTime;
})
.sort((a, b) => a.start.localeCompare(b.start))
.map(prog => ({
title: prog.title,
subtitle: prog["sub-title"],
description: prog.desc,
start: prog.start,
stop: prog.stop,
episodeNum: Array.isArray(prog["episode-num"])
? prog["episode-num"][0]
: prog["episode-num"],
rating: Array.isArray(prog.rating)
? prog.rating[0]?.value
: prog.rating?.value,
date: prog.date,
}));
return schedule;
}
/**
* Search programmes by title or description
*/
async function searchProgrammes(query: string) {
const data = await getXmltvData();
const lowerQuery = query.toLowerCase();
const results = data.tv.programme
.filter(prog => {
const title = prog.title?.toLowerCase() || "";
const subtitle = prog["sub-title"]?.toLowerCase() || "";
const desc = prog.desc?.toLowerCase() || "";
return title.includes(lowerQuery) ||
subtitle.includes(lowerQuery) ||
desc.includes(lowerQuery);
})
.map(prog => {
const channel = data.tv.channel.find(ch => ch.id === prog.channel);
return {
channel: {
id: prog.channel,
name: channel?.["display-name"] || prog.channel,
},
title: prog.title,
subtitle: prog["sub-title"],
description: prog.desc,
start: prog.start,
stop: prog.stop,
episodeNum: Array.isArray(prog["episode-num"])
? prog["episode-num"][0]
: prog["episode-num"],
};
})
.slice(0, 50); // Limit to 50 results
return results;
}
/**
* Get detailed information about a programme
*/
async function getProgrammeDetails(channelId: string, startTime: string) {
const data = await getXmltvData();
const programme = data.tv.programme.find(
prog => prog.channel === channelId && prog.start === startTime
);
if (!programme) {
throw new Error("Programme not found");
}
const channel = data.tv.channel.find(ch => ch.id === channelId);
return {
channel: {
id: channelId,
name: channel?.["display-name"] || channelId,
},
title: programme.title,
subtitle: programme["sub-title"],
description: programme.desc,
start: programme.start,
stop: programme.stop,
episodeNum: programme["episode-num"],
rating: programme.rating,
date: programme.date,
icon: programme.icon,
image: programme.image,
};
}
const server = new Server(
{
name: "xmltv-mcp",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
const tools: Tool[] = [
{
name: "get_channels",
description: "Get list of all available TV channels",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "get_now_playing",
description: "Get currently playing programmes on all channels",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "get_schedule",
description: "Get upcoming schedule for a specific channel",
inputSchema: {
type: "object",
properties: {
channel_id: {
type: "string",
description: "Channel ID (e.g., C1.49.tunarr.com)",
},
hours_ahead: {
type: "number",
description: "Number of hours to look ahead (default: 24)",
},
},
required: ["channel_id"],
},
},
{
name: "search_programmes",
description: "Search programmes by title, subtitle, or description",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
},
},
required: ["query"],
},
},
{
name: "get_programme_details",
description: "Get detailed information about a specific programme",
inputSchema: {
type: "object",
properties: {
channel_id: {
type: "string",
description: "Channel ID",
},
start_time: {
type: "string",
description: "Programme start time in XMLTV format (YYYYMMDDHHMMSS +0000)",
},
},
required: ["channel_id", "start_time"],
},
},
];
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
switch (request.params.name) {
case "get_channels": {
const channels = await getChannels();
return {
content: [
{
type: "text",
text: JSON.stringify(channels, null, 2),
},
],
};
}
case "get_now_playing": {
const nowPlaying = await getNowPlaying();
return {
content: [
{
type: "text",
text: JSON.stringify(nowPlaying, null, 2),
},
],
};
}
case "get_schedule": {
const { channel_id, hours_ahead } = request.params.arguments as {
channel_id: string;
hours_ahead?: number;
};
const schedule = await getSchedule(channel_id, hours_ahead);
return {
content: [
{
type: "text",
text: JSON.stringify(schedule, null, 2),
},
],
};
}
case "search_programmes": {
const { query } = request.params.arguments as { query: string };
const results = await searchProgrammes(query);
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, 2),
},
],
};
}
case "get_programme_details": {
const { channel_id, start_time } = request.params.arguments as {
channel_id: string;
start_time: string;
};
const details = await getProgrammeDetails(channel_id, start_time);
return {
content: [
{
type: "text",
text: JSON.stringify(details, null, 2),
},
],
};
}
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
isError: true,
};
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("XMLTV MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});