TfNSW Realtime Alerts MCP Server
by piddlingtuna
#!/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)
}
type TransportMode = {
id: string
name: string
description: string
}
/**
* Transport modes available in the API
*/
const TRANSPORT_MODES: TransportMode[] = [
{ 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" }
]
// Define interfaces for the API response
interface AlertTranslation {
text: string
language?: string
}
interface AlertTextTranslation {
translation?: AlertTranslation[]
}
interface AlertEntity {
id: string
alert: {
headerText?: AlertTextTranslation
descriptionText?: AlertTextTranslation
informedEntity?: {
routeId?: string
stopId?: string
agencyId?: string
directionId?: number
}[]
activePeriod?: {
start?: string
end?: string
}[]
cause?: string
effect?: string
url?: AlertTextTranslation
}
}
interface AlertResponse {
header?: {
gtfsRealtimeVersion?: string
incrementality?: string
timestamp?: string
}
entity: AlertEntity[]
}
/**
* 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: string): Promise<AlertResponse> => {
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() as AlertResponse
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: AlertResponse): string => {
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 as { mode: string }
// 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)
})