UNPKG

tfnsw-realtime-alerts-mcp-server

Version:

A Model Context Protocol server for Transport for NSW's (TfNSW) Realtime Alerts API

342 lines (341 loc) 13.3 kB
#!/usr/bin/env node /** * TfNSW Realtime Alerts MCP Server. * This server provides real-time service alerts for Transport for NSW. * - Fetching realtime alerts by mode via resources * - Fetching realtime alerts by mode via tools * - Summarising realtime alerts via prompts. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import * as dotenv from "dotenv"; import fetch from "node-fetch"; // Load environment variables. dotenv.config(); const API_KEY = process.env.NSW_TRANSPORT_API_KEY; const BASE_URL = "https://api.transport.nsw.gov.au/v2/gtfs/alerts"; if (!API_KEY) { console.error("Error: NSW_TRANSPORT_API_KEY environment variable is not set!"); console.error("Please obtain an API key from https://opendata.transport.nsw.gov.au/"); process.exit(1); } /** * Transport modes available in the API */ const TRANSPORT_MODES = [ { id: "all", name: "All Transport Modes", description: "Service alerts for all transport modes" }, { id: "buses", name: "Buses", description: "Service alerts for all Sydney Metro and Outer Metro Bus Contract Regions" }, { id: "ferries", name: "Ferries", description: "Service alerts for Sydney Ferries, Newcastle Transport ferries, and private operators" }, { id: "lightrail", name: "Light Rail", description: "Service alerts for Light Rail" }, { id: "metro", name: "Sydney Metro", description: "Service alerts for Sydney Metro" }, { id: "nswtrains", name: "NSW Trains", description: "Service alerts for NSW Trains regional trains and coaches" }, { id: "regionbuses", name: "Regional Buses", description: "Service alerts for Regional Buses" }, { id: "sydneytrains", name: "Sydney Trains", description: "Service alerts for Sydney Trains suburban and intercity network" } ]; /** * Create an MCP server with capabilities for resources (to list/read alerts), * tools (to fetch specific alerts), and prompts (to summarise alerts). */ const server = new Server({ name: "TfNSW Realtime Alerts MCP Server", version: "0.1.0", }, { capabilities: { resources: {}, tools: {}, prompts: {}, }, }); /** * Fetch alerts using the TfNSW API * @param mode The transport mode to fetch alerts for * @returns The alert data */ const fetchAlerts = async (mode) => { try { const response = await fetch(`${BASE_URL}/${mode}?format=json`, { headers: { "Authorization": `apikey ${API_KEY}` } }); if (!response.ok) { throw new Error(`Failed to fetch ${mode} alerts: ${response.status} ${response.statusText}`); } const data = await response.json(); return data; } catch (error) { console.error(`Error fetching ${mode} alerts:`, error); throw error; } }; /** * Format alerts into human-readable text * @param data The alert data from the API * @returns Formatted alert text */ const formatAlerts = (data) => { if (!data || !data.entity || !Array.isArray(data.entity)) { return "No alerts found or invalid data format."; } if (data.entity.length === 0) { return "No current service alerts."; } return data.entity .filter(entity => entity.alert) .map(entity => { const alert = entity.alert; // Get header text const header = alert.headerText?.translation?.[0]?.text || "Unknown Alert"; // Get description const description = alert.descriptionText?.translation?.[0]?.text || "No details available"; // Get affected routes/stops if available let affectedServices = ""; if (alert.informedEntity && alert.informedEntity.length > 0) { const routes = alert.informedEntity .filter(entity => entity.routeId) .map(entity => entity.routeId) .filter((value, index, self) => value && self.indexOf(value) === index); const stops = alert.informedEntity .filter(entity => entity.stopId) .map(entity => entity.stopId) .filter((value, index, self) => value && self.indexOf(value) === index); const agencies = alert.informedEntity .filter(entity => entity.agencyId) .map(entity => entity.agencyId) .filter((value, index, self) => value && self.indexOf(value) === index); if (routes && routes.length > 0) { affectedServices += `\nAffected Routes: ${routes.join(", ")}`; } if (stops && stops.length > 0) { affectedServices += `\nAffected Stops: ${stops.join(", ")}`; } if (agencies && agencies.length > 0) { affectedServices += `\nAffected Agencies: ${agencies.join(", ")}`; } } // Get time period if available let timePeriod = ""; if (alert.activePeriod && alert.activePeriod.length > 0) { alert.activePeriod.forEach((period, index) => { if (period.start) { const startDate = new Date(parseInt(period.start) * 1000); timePeriod += `\nPeriod ${index + 1} Start: ${startDate.toLocaleString()}`; } if (period.end) { const endDate = new Date(parseInt(period.end) * 1000); timePeriod += `\nPeriod ${index + 1} End: ${endDate.toLocaleString()}`; } }); } // Get cause and effect if available const cause = alert.cause ? `\nCause: ${alert.cause}` : ""; const effect = alert.effect ? `\nEffect: ${alert.effect}` : ""; // Get URL if available const url = alert.url?.translation?.[0]?.text ? `\nMore info: ${alert.url.translation[0].text}` : ""; return ` === ${header} === ${description}${affectedServices}${timePeriod}${cause}${effect}${url} -------------------------------------------`; }) .join("\n"); }; /** * Handler for listing available transport modes as resources. * Each mode is exposed as a resource with: * - A nsw-transport:// URI scheme * - Plain text MIME type * - Human readable name and description */ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: TRANSPORT_MODES.map(mode => ({ uri: `nsw-transport://${mode.id}`, mimeType: "text/plain", name: `${mode.name} Alerts`, description: mode.description })) })); /** * Handler for reading the contents of a specific transport mode's alerts. * Takes a nsw-transport:// URI and returns the alerts as plain text. */ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { // Extract mode from URI const match = request.params.uri.match(/^nsw-transport:\/\/(.+)$/); if (!match) { throw new Error(`Invalid resource URI: ${request.params.uri}`); } const modeId = match[1]; // Validate the mode const modeInfo = TRANSPORT_MODES.find(mode => mode.id === modeId); if (!modeInfo) { throw new Error(`Invalid transport mode: ${modeId}. Valid modes are: ${TRANSPORT_MODES.map(m => m.id).join(", ")}`); } // Fetch and format alerts const data = await fetchAlerts(modeId); const formattedAlerts = formatAlerts(data); return { contents: [{ uri: request.params.uri, mimeType: "text/plain", text: `# Service Alerts for ${modeInfo.name}\n\n${formattedAlerts}` }] }; } catch (error) { console.error(`Error reading resource ${request.params.uri}:`, error); throw error; } }); /** * Handler that lists available tools. * Exposes a single "get-transport-alerts" tool that fetches alerts for a specific mode. */ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "get-transport-alerts", description: "Get Transport for NSW service alerts for a specific mode of transport", inputSchema: { type: "object", properties: { mode: { type: "string", enum: TRANSPORT_MODES.map(mode => mode.id), description: "Transport mode to get alerts for" } }, required: ["mode"] } }, ] })); /** * Handler for the get-transport-alerts tool. * Fetches alerts for the specified transport mode and returns them formatted. */ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "get-transport-alerts") { try { const { mode } = args; // Validate the mode const modeInfo = TRANSPORT_MODES.find(m => m.id === mode); if (!modeInfo) { return { isError: true, content: [{ type: "text", text: `Invalid transport mode: ${mode}. Valid modes are: ${TRANSPORT_MODES.map(m => m.id).join(", ")}` }] }; } // Fetch and format alerts const data = await fetchAlerts(mode); const formattedAlerts = formatAlerts(data); return { content: [{ type: "text", text: `# Service Alerts for ${modeInfo.name}\n\n${formattedAlerts}` }] }; } catch (error) { console.error("Error executing get-transport-alerts:", error); return { isError: true, content: [{ type: "text", text: `Failed to fetch alerts: ${error instanceof Error ? error.message : String(error)}` }] }; } } return { isError: true, content: [{ type: "text", text: `Unknown tool: ${name}` }] }; }); /** * Handler that lists available prompts. * Exposes a single "transport-disruption-summary" prompt that summarises all alerts. */ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [ { name: "transport-disruption-summary", description: "Get a summary of all current transport disruptions", } ] })); /** * Handler for the transport-disruption-summary prompt. * Returns a prompt that requests summarisation of all transport alerts. */ server.setRequestHandler(GetPromptRequestSchema, async (request) => { if (request.params.name !== "transport-disruption-summary") { throw new Error("Unknown prompt"); } try { // Fetch alerts for all transport modes const allAlerts = await fetchAlerts("all"); const formattedAlerts = formatAlerts(allAlerts); return { messages: [ { role: "user", content: { type: "text", text: "Please summarise the following NSW Transport alerts and advise on major disruptions:" } }, { role: "user", content: { type: "resource", resource: { uri: "nsw-transport://all", mimeType: "text/plain", text: formattedAlerts } } }, { role: "user", content: { type: "text", text: "Provide a concise summary of major disruptions affecting NSW transport services. Highlight the most significant delays or service changes, affected areas, and suggest alternative transport options where applicable." } } ] }; } catch (error) { console.error("Error preparing transport-disruption-summary prompt:", error); throw error; } }); /** * Start the server using stdio transport. * This allows the server to communicate via standard input/output streams. */ const main = async () => { try { const transport = new StdioServerTransport(); await server.connect(transport); console.error("NSW Transport Alerts MCP Server is running"); } catch (error) { console.error("Failed to start server:", error); process.exit(1); } }; main().catch((error) => { console.error("Server error:", error); process.exit(1); });