Skip to main content
Glama
toolHandlers.ts28.3 kB
import { fetchMTAData, fetchMTAAlerts } from "../services/mtaService.js"; import { StationMatcher, getStopsData, getTransfersData, getGTFSSourceInfo, refreshGTFS, ensureDataLoaded } from "../services/stationService.js"; import { calculateDistance, getTrainDestination, getCurrentLocation, getMTADisclaimer } from "../utils/index.js"; import { ServiceDisruptionAnalyzer } from "../services/serviceDisruptions.js"; import { ToolResponse, FindStationArgs, NextTrainsArgs, ServiceStatusArgs, SubwayAlertsArgs, StationTransfersArgs, NearestStationArgs, ServiceDisruptionsArgs, GTFSEntity, MTAFeedData, Stop, Transfer } from "../types/index.js"; // New tool handlers export async function handleServiceDisruptions(args: ServiceDisruptionsArgs): Promise<ToolResponse> { try { const alertsResult = await handleSubwayAlerts({ line: args.line, severity: args.severity as SubwayAlertsArgs['severity'], active_only: true }); const statusResult = await handleServiceStatus({ line: args.line }); return await ServiceDisruptionAnalyzer.analyze(args, alertsResult, statusResult); } catch (error) { console.error('Service disruption analysis error occurred'); return { content: [{ type: "text", text: "Service disruption analysis temporarily unavailable. Please try again later." }], isError: true }; } } export async function handleGTFSStatus(args: Record<string, never>): Promise<ToolResponse> { try { const sourceInfo = await getGTFSSourceInfo(); const result = { timestamp: Date.now(), timezone: 'America/New_York', currentSource: sourceInfo.source, dataSources: { regular: "https://rrgtfsfeeds.s3.amazonaws.com/gtfs_subway.zip", supplemented: "https://rrgtfsfeeds.s3.amazonaws.com/gtfs_supplemented.zip", localFallback: "data/gtfs_subway/" }, status: { regular: { updateFrequency: "few times per year", cached: sourceInfo.status.regular.cached, ageMinutes: sourceInfo.status.regular.cached ? sourceInfo.status.regular.age : null, nextUpdate: sourceInfo.status.regular.cached ? sourceInfo.status.regular.nextUpdate : null, isStale: sourceInfo.status.regular.cached ? sourceInfo.status.regular.stale : null }, supplemented: { updateFrequency: "hourly", cached: sourceInfo.status.supplemented.cached, ageMinutes: sourceInfo.status.supplemented.cached ? sourceInfo.status.supplemented.age : null, nextUpdate: sourceInfo.status.supplemented.cached ? sourceInfo.status.supplemented.nextUpdate : null, isStale: sourceInfo.status.supplemented.cached ? sourceInfo.status.supplemented.stale : null } } }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: "GTFS status temporarily unavailable. Please try again later.", errorType: "SERVICE_ERROR" }) }], isError: true }; } } export async function handleFindStation(args: FindStationArgs): Promise<ToolResponse> { await ensureDataLoaded(); const stationQuery = args?.query?.toLowerCase() || ""; if (!stationQuery.trim()) { return { content: [{ type: "text", text: "Please provide a valid station name to search for." }], isError: true }; } const query = args.query.trim(); const includeAccessibility = args.include_accessibility || false; const includeAmenities = args.include_amenities || false; try { const stopsData = getStopsData(); const matches = StationMatcher.findBestMatches(query, stopsData); const stations = matches.map(stop => stop.stop_name); const uniqueStations = [...new Set(stations)].sort(); if (uniqueStations.length === 0) { return { content: [{ type: "text", text: JSON.stringify({ error: `No stations found matching "${query}". Try a different spelling or use partial names.`, errorType: "STATION_NOT_FOUND", searchQuery: query, suggestions: ["Try partial names", "Check spelling", "Use common abbreviations"] }) }], isError: true }; } const result = { searchQuery: query, stationsFound: uniqueStations.length, stations: uniqueStations, accessibility: includeAccessibility ? { note: "Detailed accessibility data requires MTA API integration", recommendation: "Check mta.info for current elevator/escalator status" } : null, amenities: includeAmenities ? { wifi: "Available at most stations", restrooms: "Limited availability, check station maps", atms: "Available at major stations" } : null, timestamp: Date.now() }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: "Station search temporarily unavailable. Please try again later.", errorType: "SERVICE_ERROR" }) }], isError: true }; } } export async function handleNextTrains(args: NextTrainsArgs): Promise<ToolResponse> { await ensureDataLoaded(); const stationQuery = args?.station?.toLowerCase() || ""; if (!stationQuery.trim()) { return { content: [{ type: "text", text: JSON.stringify({ error: "Please provide a valid station name.", errorType: "INVALID_INPUT" }) }], isError: true }; } const lineFilter = args.line?.toUpperCase(); const directionFilter = args.direction?.toLowerCase(); const limit = Math.min(args.limit || 5, 10); const stopsData = getStopsData(); const validStations = StationMatcher.findBestMatches(stationQuery, stopsData); if (validStations.length === 0) { return { content: [{ type: "text", text: JSON.stringify({ error: `No stations found matching "${stationQuery}". Try a different spelling or use partial names.`, errorType: "STATION_NOT_FOUND", searchQuery: stationQuery }) }], isError: true }; } const validStationNames = validStations.map(s => s.stop_name.toLowerCase().trim()); const data = await fetchMTAData(); const arrivals: any[] = []; // Process entities with async operations for (const entity of data.entity || []) { if (!entity.tripUpdate) continue; const trip = entity.tripUpdate.trip; const stopTimeUpdates = entity.tripUpdate.stopTimeUpdate || []; if (lineFilter && trip.routeId !== lineFilter) continue; for (const update of stopTimeUpdates) { const stopRecord = stopsData.find(stop => stop.stop_id === update.stopId); const stationName = stopRecord ? stopRecord.stop_name : update.stopId; const stationNameLower = stationName.toLowerCase().trim(); if (validStationNames.includes(stationNameLower)) { // Get vehicle position for this trip const vehicleEntity = data.entity?.find((e: any) => e.vehicle?.trip?.tripId === trip.tripId ); const destination = await getTrainDestination(stopTimeUpdates); const currentLocation = await getCurrentLocation(vehicleEntity?.vehicle); const arrivalTime = update.arrival?.time; const delay = update.arrival?.delay || 0; // Extract real crowding data from vehicle position const occupancyStatus = vehicleEntity?.vehicle?.occupancyStatus; const occupancyPercentage = vehicleEntity?.vehicle?.occupancyPercentage; const departureOccupancy = update.departureOccupancyStatus; // Store raw arrival timestamp for sorting let arrivalTimestamp = null; let isValidTime = false; if (arrivalTime) { arrivalTimestamp = Number(arrivalTime) * 1000; // Convert seconds to milliseconds const now = Date.now(); const timeDiff = arrivalTimestamp - now; isValidTime = timeDiff > 0 && timeDiff < 2 * 60 * 60 * 1000; // Within next 2 hours } arrivals.push({ line: trip.routeId, lineDisplayName: trip.routeId === 'FS' ? 'Franklin Shuttle' : trip.routeId === 'S' ? '42nd St Shuttle' : trip.routeId === 'SR' ? 'Lefferts Blvd Shuttle' : trip.routeId === 'SF' ? 'Shuttle' : trip.routeId, destination, currentLocation, arrivalTimestamp, isValidTime, delay, station: stationName, occupancyStatus, occupancyPercentage, departureOccupancy: departureOccupancy || occupancyStatus, tripId: trip.tripId }); } } } // Sort by arrival time (valid times first, then invalid) arrivals.sort((a, b) => { if (a.isValidTime && !b.isValidTime) return -1; if (!a.isValidTime && b.isValidTime) return 1; if (a.isValidTime && b.isValidTime) return a.arrivalTimestamp - b.arrivalTimestamp; return 0; }); if (arrivals.length === 0) { return { content: [{ type: "text", text: JSON.stringify({ error: `No upcoming trains found for station matching "${stationQuery}"`, errorType: "NO_TRAINS_FOUND", searchQuery: stationQuery, matchedStations: validStations.map(s => s.stop_name) }) }], isError: true }; } // Return structured data const result = { station: arrivals[0]?.station || stationQuery, matchedStations: validStations.map(s => s.stop_name), filters: { line: lineFilter || null, direction: directionFilter || null, limit }, trains: arrivals.slice(0, limit).map(arrival => ({ line: arrival.line, destination: arrival.destination, currentLocation: arrival.currentLocation, arrivalTimestamp: arrival.arrivalTimestamp, isValidTime: arrival.isValidTime, delay: arrival.delay, occupancy: { status: arrival.occupancyStatus, percentage: arrival.occupancyPercentage, departureStatus: arrival.departureOccupancy }, tripId: arrival.tripId })), totalFound: arrivals.length, timestamp: Date.now(), timezone: 'America/New_York' }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } export async function handleServiceStatus(args: ServiceStatusArgs): Promise<ToolResponse> { const lineFilter = args?.line?.toUpperCase(); const includeMetrics = args?.include_metrics || false; const timeRange = args?.time_range || 'current'; try { const data = await fetchMTAData(); // Get service alerts const alerts = data.entity?.filter((entity: any) => entity.alert) || []; // Get general stats const stats = { tripUpdates: 0, vehiclePositions: 0, alerts: alerts.length }; data.entity?.forEach((entity: any) => { if (entity.tripUpdate) stats.tripUpdates++; if (entity.vehicle) stats.vehiclePositions++; }); // Process alerts data const alertsData = alerts.slice(0, 3).map((entity: any) => { const alert = entity.alert; return { header: alert.headerText?.translation?.[0]?.text || null, description: alert.descriptionText?.translation?.[0]?.text || null, effect: alert.effect || null, informedEntities: alert.informedEntity?.map((ie: any) => ({ routeId: ie.routeId, stopId: ie.stopId })) || [] }; }); // Line-specific data if requested let lineSpecific = null; if (lineFilter) { const lineTrips = data.entity?.filter((entity: any) => entity.tripUpdate?.trip?.routeId === lineFilter ) || []; lineSpecific = { line: lineFilter, activeTrains: lineTrips.length }; } const result = { timestamp: Date.now(), timezone: 'America/New_York', filters: { line: lineFilter || null, includeMetrics, timeRange }, systemStats: { activeTrips: stats.tripUpdates, vehiclePositions: stats.vehiclePositions, totalAlerts: stats.alerts }, alerts: alertsData, lineSpecific, performanceMetrics: includeMetrics ? { note: "Performance metrics require MTA Performance API integration", availableData: "Real-time service data available through alerts and trip updates", officialReports: "Visit mta.info for official performance reports" } : null }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: "Service status temporarily unavailable. Please try again later.", errorType: "SERVICE_ERROR" }) }], isError: true }; } } export async function handleSubwayAlerts(args: SubwayAlertsArgs): Promise<ToolResponse> { const lineFilter = args?.line?.toUpperCase(); const severityFilter = args?.severity?.toUpperCase(); const categoryFilter = args?.category?.toUpperCase(); const activeOnly = args?.active_only !== false; // Default true try { const alertsData = await fetchMTAAlerts(); const alerts = alertsData.entity?.filter((entity: any) => entity.alert) || []; let filteredAlerts = alerts; // Filter by line if specified if (lineFilter && lineFilter !== 'ALL') { filteredAlerts = filteredAlerts.filter((entity: any) => { const alert = entity.alert; const informedEntities = alert.informedEntity || []; return informedEntities.some((ie: any) => ie.routeId === lineFilter); }); } // Filter by severity if specified if (severityFilter && severityFilter !== 'ALL') { filteredAlerts = filteredAlerts.filter((entity: any) => { const alert = entity.alert; return alert.effect === severityFilter; }); } // Filter by category if specified (basic mapping) if (categoryFilter && categoryFilter !== 'ALL') { filteredAlerts = filteredAlerts.filter((entity: any) => { const alert = entity.alert; const header = alert.headerText?.translation?.[0]?.text?.toUpperCase() || ''; const description = alert.descriptionText?.translation?.[0]?.text?.toUpperCase() || ''; switch (categoryFilter) { case 'DELAYS': return header.includes('DELAY') || description.includes('DELAY') || alert.effect === 'SIGNIFICANT_DELAYS'; case 'SUSPENSIONS': return header.includes('SUSPEND') || description.includes('SUSPEND') || alert.effect === 'NO_SERVICE'; case 'REROUTES': return header.includes('REROUTE') || description.includes('REROUTE') || alert.effect === 'DETOUR'; case 'PLANNED_WORK': return header.includes('WORK') || description.includes('WORK') || header.includes('WEEKEND'); case 'ACCESSIBILITY': return header.includes('ELEVATOR') || description.includes('ELEVATOR') || header.includes('ACCESSIBLE'); default: return true; } }); } // Filter by active period if activeOnly is true if (activeOnly) { const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds filteredAlerts = filteredAlerts.filter((entity: any) => { const alert = entity.alert; const activePeriods = alert.activePeriod || []; if (activePeriods.length === 0) return true; // No period specified means always active return activePeriods.some((period: any) => { const start = period.start ? Number(period.start) : 0; const end = period.end ? Number(period.end) : Number.MAX_SAFE_INTEGER; return now >= start && now <= end; }); }); } if (filteredAlerts.length === 0) { const result = { timestamp: Date.now(), timezone: 'America/New_York', filters: { line: lineFilter || null, severity: severityFilter || null, category: categoryFilter || null, activeOnly }, summary: { totalAlertsFound: 0, alertsReturned: 0, hasMoreAlerts: false }, alerts: [], status: "NO_ACTIVE_ALERTS", message: "No active alerts found for the specified criteria - service is running normally" }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } // Process alerts into structured data const processedAlerts = filteredAlerts.slice(0, 10).map((entity: any, index: number) => { const alert = entity.alert; // Extract affected lines and stations const informedEntities = alert.informedEntity || []; const affectedLines = [...new Set(informedEntities.map((ie: any) => ie.routeId).filter(Boolean))]; const affectedStations = [...new Set(informedEntities.map((ie: any) => ie.stopId).filter(Boolean))]; // Process active periods const activePeriods = (alert.activePeriod || []).map((period: any) => ({ startTimestamp: period.start ? Number(period.start) * 1000 : null, endTimestamp: period.end ? Number(period.end) * 1000 : null, isCurrentlyActive: (() => { const now = Date.now(); const start = period.start ? Number(period.start) * 1000 : 0; const end = period.end ? Number(period.end) * 1000 : Number.MAX_SAFE_INTEGER; return now >= start && now <= end; })() })); // Get full description without truncation (let AI decide how to present it) const fullDescription = alert.descriptionText?.translation?.[0]?.text || null; return { id: entity.id || `alert_${index}`, rank: index + 1, header: alert.headerText?.translation?.[0]?.text || null, description: { full: fullDescription, length: fullDescription ? fullDescription.length : 0, isTruncated: fullDescription ? fullDescription.length > 200 : false }, effect: alert.effect || null, cause: alert.cause || null, severity: alert.severityLevel || null, affectedLines, affectedStations, activePeriods, url: alert.url?.translation?.[0]?.text || null, isCurrentlyActive: activePeriods.length === 0 || activePeriods.some((p: any) => p.isCurrentlyActive) }; }); const result = { timestamp: Date.now(), timezone: 'America/New_York', filters: { line: lineFilter || null, severity: severityFilter || null, category: categoryFilter || null, activeOnly }, summary: { totalAlertsFound: filteredAlerts.length, alertsReturned: processedAlerts.length, hasMoreAlerts: filteredAlerts.length > 10 }, alerts: processedAlerts }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: "Subway alerts temporarily unavailable. Please try again later.", errorType: "SERVICE_ERROR" }) }], isError: true }; } } export async function handleStationTransfers(args: StationTransfersArgs): Promise<ToolResponse> { await ensureDataLoaded(); const stationQuery = args?.station?.toLowerCase() || ""; if (!stationQuery.trim()) { return { content: [{ type: "text", text: JSON.stringify({ error: "Please provide a station name to search for transfers.", errorType: "MISSING_STATION", examples: ["Times Square", "Union Square", "Atlantic Ave"] }) }], isError: true }; } try { const stopsData = getStopsData(); const transfersData = getTransfersData(); // Find matching stations from stops data let matchingStops = stopsData.filter(stop => stop.stop_name?.toLowerCase() === stationQuery && stop.location_type === '1' // Parent stations only ); // If no exact match, try partial match but prioritize shorter names if (matchingStops.length === 0) { const partialMatches = stopsData.filter(stop => stop.stop_name?.toLowerCase().includes(stationQuery) && stop.location_type === '1' // Parent stations only ); // Sort by name length to prefer shorter, more specific matches matchingStops = partialMatches.sort((a, b) => a.stop_name.length - b.stop_name.length); } if (matchingStops.length === 0) { return { content: [{ type: "text", text: JSON.stringify({ error: `No stations found matching "${args?.station}".`, errorType: "STATION_NOT_FOUND", searchQuery: args?.station, suggestions: ["Try partial names like 'Times' or 'Union'", "Check spelling", "Use common station abbreviations"] }) }], isError: true }; } const result = { searchQuery: args?.station, stationsFound: matchingStops.length, stations: matchingStops.slice(0, 3).map(station => { // Find all transfers involving this station const stationTransfers = transfersData.filter(transfer => transfer.from_stop_id === station.stop_id || transfer.to_stop_id === station.stop_id ); if (stationTransfers.length === 0) { return { name: station.stop_name, stopId: station.stop_id, transfers: [], hasTransferInfo: false }; } // Group transfers by connected stations const connections = new Map(); stationTransfers.forEach(transfer => { const otherStopId = transfer.from_stop_id === station.stop_id ? transfer.to_stop_id : transfer.from_stop_id; const otherStop = stopsData.find(s => s.stop_id === otherStopId); if (otherStop && otherStop.stop_id !== station.stop_id) { const transferTimeMinutes = transfer.min_transfer_time ? Math.round(Number(transfer.min_transfer_time) / 60) : null; connections.set(otherStop.stop_name, { stationName: otherStop.stop_name, stopId: otherStop.stop_id, transferTimeMinutes }); } }); return { name: station.stop_name, stopId: station.stop_id, transfers: Array.from(connections.values()).sort((a, b) => a.stationName.localeCompare(b.stationName) ), hasTransferInfo: connections.size > 0, withinStationTransfers: connections.size === 0 }; }), timestamp: Date.now() }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: "Transfer information temporarily unavailable. Please try again later.", errorType: "SERVICE_ERROR" }) }], isError: true }; } } export async function handleNearestStation(args: NearestStationArgs): Promise<ToolResponse> { await ensureDataLoaded(); const limit = Math.min(Number(args?.limit) || 5, 15); // Cap at 15 const radius = Math.min(Number(args?.radius) || 1000, 2000); // Cap at 2km const accessibleOnly = args?.accessible_only || false; const includeWalkingDirections = args?.include_walking_directions || false; const serviceFilter = args?.service_filter || []; // Require GPS coordinates if (args?.lat === undefined || args?.lon === undefined) { return { content: [{ type: "text", text: JSON.stringify({ error: "GPS coordinates are required. Please provide both lat and lon parameters.", errorType: "MISSING_COORDINATES", example: { lat: 40.7589, lon: -73.9851, description: "Times Square" }, note: "AI/LLM should convert location names to coordinates before calling this tool" }) }], isError: true }; } const lat = Number(args.lat); const lon = Number(args.lon); if (isNaN(lat) || isNaN(lon)) { return { content: [{ type: "text", text: JSON.stringify({ error: "Please provide valid latitude and longitude coordinates.", errorType: "INVALID_COORDINATES", example: { lat: 40.7589, lon: -73.9851, description: "Times Square" } }) }], isError: true }; } try { const stopsData = getStopsData(); // Find parent stations (location_type = 1) within radius const nearbyStations = stopsData .filter(stop => stop.location_type === '1' && stop.stop_lat && stop.stop_lon) .map(stop => { const distance = calculateDistance( lat, lon, Number(stop.stop_lat), Number(stop.stop_lon) ); return { ...stop, distance }; }) .filter(stop => stop.distance <= radius) .sort((a, b) => a.distance - b.distance) .slice(0, limit); if (nearbyStations.length === 0) { return { content: [{ type: "text", text: JSON.stringify({ error: `No subway stations found within ${radius}m of the specified location.`, errorType: "NO_STATIONS_FOUND", location: { lat, lon }, searchRadius: radius, suggestion: "Try increasing the search radius" }) }], isError: true }; } const result = { searchLocation: { lat, lon }, searchRadius: radius, filters: { limit, accessibleOnly, includeWalkingDirections, serviceFilter: serviceFilter.length > 0 ? serviceFilter : null }, stationsFound: nearbyStations.length, stations: nearbyStations.map((station, i) => { const walkingTime = Math.ceil(station.distance / 80); // ~80m/min walking speed return { rank: i + 1, name: station.stop_name, stopId: station.stop_id, coordinates: { lat: Number(station.stop_lat), lon: Number(station.stop_lon) }, distance: { meters: Math.round(station.distance), walkingTimeMinutes: walkingTime }, walkingDirection: includeWalkingDirections ? { primary: station.stop_lat > lat ? 'north' : 'south', secondary: station.stop_lon > lon ? 'east' : 'west' } : null, accessibility: accessibleOnly ? { wheelchairAccessible: true } : null }; }), walkingTips: includeWalkingDirections ? [ "Use street-level signs to locate station entrances", "Check for elevator access if needed", "Allow extra time during rush hours" ] : null, timestamp: Date.now() }; return { content: [{ type: "text", text: JSON.stringify(result) }] }; } catch (error) { return { content: [{ type: "text", text: JSON.stringify({ error: "Station search temporarily unavailable. Please try again later.", errorType: "SERVICE_ERROR" }) }], isError: true }; } }

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/sasabasara/where_is_my_train_mcp'

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