Skip to main content
Glama
icyrainz
by icyrainz
index.ts11 kB
#!/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); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/icyrainz/xmltv-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server