@pipeworx/mcp-amtrak 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pipeworx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # mcp-amtrak
2
+
3
+ Amtrak MCP — live Amtrak train tracking via the community Amtraker API
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1314+ live data sources.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+
12
+ ## Quick Start
13
+
14
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "amtrak": {
20
+ "url": "https://gateway.pipeworx.io/amtrak/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 1314+ data sources:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "pipeworx": {
32
+ "url": "https://gateway.pipeworx.io/mcp"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Using with ask_pipeworx
39
+
40
+ Instead of calling tools directly, you can ask questions in plain English:
41
+
42
+ ```
43
+ ask_pipeworx({ question: "your question about Amtrak data" })
44
+ ```
45
+
46
+ The gateway picks the right tool and fills the arguments automatically.
47
+
48
+ ## More
49
+
50
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
51
+ - [pipeworx.io](https://pipeworx.io)
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-amtrak",
3
+ "version": "0.1.0",
4
+ "description": "Amtrak MCP — live Amtrak train tracking via the community Amtraker API",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "amtrak"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-amtrak"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/amtrak",
4
+ "title": "Amtrak",
5
+ "description": "Amtrak MCP — live Amtrak train tracking via the community Amtraker API",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/amtrak",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-amtrak",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/amtrak/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,515 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ properties: Record<string, unknown>;
7
+ required?: string[];
8
+ };
9
+ }
10
+
11
+ interface McpToolExport {
12
+ tools: McpToolDefinition[];
13
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
17
+ }
18
+
19
+ /**
20
+ * Amtrak MCP — live Amtrak train tracking via the community Amtraker API
21
+ * (api-v3.amtraker.com, a mirror of Amtrak's own track-a-train feed).
22
+ *
23
+ * Tools:
24
+ * - amtrak_train_status: where is train N right now (GPS, speed, delay, next stop)
25
+ * - amtrak_station_board: upcoming arrivals at a station (departures board)
26
+ * - amtrak_routes_active: all currently active trains grouped by route
27
+ * - amtrak_station_info: station lookup (name, code, address, inbound trains)
28
+ *
29
+ * Keyless. Positions update roughly every few minutes. The feed also carries
30
+ * Via Rail (train numbers prefixed "v") and Brightline (prefixed "b") trains.
31
+ *
32
+ * Quirks learned from probing:
33
+ * - /trains/{num} and /stations/{code} return `[]` with HTTP 200 when unknown.
34
+ * - /trains is keyed by train number; each value is an ARRAY of active
35
+ * instances (trainID = "num-departureDayOfMonth", e.g. "6-14").
36
+ * - Timestamps are ISO 8601 with local UTC offsets (e.g. 2026-07-15T15:28:00-06:00).
37
+ * - `trainTimely` is empty in practice — timeliness is computed here from
38
+ * scheduled vs estimated times at the next station.
39
+ * - For future stations, `arr`/`dep` hold ESTIMATED times; for past ones, actuals.
40
+ * - `velocity` is mph; `lastValTS` is the last GPS fix time.
41
+ */
42
+
43
+
44
+ const BASE_URL = 'https://api-v3.amtraker.com/v3';
45
+
46
+ const tools: McpToolExport['tools'] = [
47
+ {
48
+ name: 'amtrak_train_status',
49
+ description:
50
+ 'Live Amtrak train status and GPS tracking — "where is my Amtrak train", "is train 6 late", "track the California Zephyr". Accepts a train number ("6", "2150") or route name ("Coast Starlight", "Empire Builder"). Returns each active instance of that train: current GPS position, speed, heading, timeliness, next station with ETA and delay minutes, and origin/destination scheduled vs actual times. Data is a community mirror of Amtrak\'s own tracking feed (api-v3.amtraker.com); positions update about every few minutes. Example: amtrak_train_status({ train: "6" })',
51
+ inputSchema: {
52
+ type: 'object' as const,
53
+ properties: {
54
+ train: {
55
+ type: 'string',
56
+ description:
57
+ 'Amtrak train number (e.g. "6", "91", "2150") or route name (e.g. "California Zephyr", "Acela", "Coast Starlight"). Via Rail ("v1") and Brightline ("b5151") trains in the same feed also work.',
58
+ },
59
+ },
60
+ required: ['train'],
61
+ },
62
+ },
63
+ {
64
+ name: 'amtrak_station_board',
65
+ description:
66
+ 'Amtrak station departures/arrivals board — upcoming trains at a station. "What trains are arriving at Chicago Union?", "next train at CHI", "station board for Denver". Accepts a 3-letter Amtrak station code ("CHI", "NYP", "LAX") or a station name ("Chicago", "Portland"). Returns trains currently en route to that station with route, scheduled vs estimated arrival, delay minutes, and status. Live community mirror of Amtrak tracking (api-v3.amtraker.com). Example: amtrak_station_board({ station: "CHI" })',
67
+ inputSchema: {
68
+ type: 'object' as const,
69
+ properties: {
70
+ station: {
71
+ type: 'string',
72
+ description: 'Amtrak station code (e.g. "CHI", "NYP", "SEA") or station/city name (e.g. "Chicago", "Denver")',
73
+ },
74
+ limit: { type: 'number', description: 'Max trains to return, 1-15 (default 15)' },
75
+ },
76
+ required: ['station'],
77
+ },
78
+ },
79
+ {
80
+ name: 'amtrak_routes_active',
81
+ description:
82
+ 'Summary of ALL currently active Amtrak trains grouped by route — "how many Amtrak trains are running right now", "which Amtrak routes have delays", national system overview. Returns per route: active train count, train numbers, and the worst delay in minutes. Compact; live from a community mirror of Amtrak\'s tracking feed. Example: amtrak_routes_active({})',
83
+ inputSchema: {
84
+ type: 'object' as const,
85
+ properties: {},
86
+ },
87
+ },
88
+ {
89
+ name: 'amtrak_station_info',
90
+ description:
91
+ 'Amtrak station lookup by code or name — station name, 3-letter code, city/state, street address, timezone, coordinates, and how many trains are currently inbound. "Where is the Amtrak station in Denver?", "what is station code NYP". Example: amtrak_station_info({ station: "Denver" })',
92
+ inputSchema: {
93
+ type: 'object' as const,
94
+ properties: {
95
+ station: {
96
+ type: 'string',
97
+ description: 'Amtrak station code (e.g. "DEN", "NYP") or station/city name (e.g. "Denver", "New York")',
98
+ },
99
+ },
100
+ required: ['station'],
101
+ },
102
+ },
103
+ ];
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // API types (fields verified live 2026-07-15)
107
+
108
+ interface TrainStop {
109
+ name: string;
110
+ code: string;
111
+ tz: string;
112
+ bus: boolean;
113
+ schArr: string;
114
+ schDep: string;
115
+ arr: string; // actual (past stops) or estimated (future stops)
116
+ dep: string;
117
+ status: 'Departed' | 'Enroute' | 'Station' | string;
118
+ platform?: string;
119
+ }
120
+
121
+ interface Train {
122
+ routeName: string;
123
+ trainNum: string;
124
+ trainID: string; // "num-departureDayOfMonth", e.g. "6-14"
125
+ lat: number;
126
+ lon: number;
127
+ heading: string; // N/NE/E/SE/S/SW/W/NW
128
+ velocity: number; // mph
129
+ trainState: 'Active' | 'Predeparture' | 'Completed' | string;
130
+ trainTimely: string; // empty in practice
131
+ statusMsg: string; // e.g. "SERVICE DISRUPTION"
132
+ lastValTS: string; // last GPS fix
133
+ origCode: string;
134
+ origName: string;
135
+ destCode: string;
136
+ destName: string;
137
+ provider: string; // Amtrak | Via | Brightline
138
+ stations: TrainStop[];
139
+ alerts?: unknown[];
140
+ }
141
+
142
+ interface Station {
143
+ name?: string; // missing on a handful of entries (mostly bus-stop-only codes)
144
+ code: string;
145
+ tz: string;
146
+ lat: number;
147
+ lon: number;
148
+ hasAddress?: boolean;
149
+ address1?: string;
150
+ address2?: string;
151
+ city?: string;
152
+ state?: string;
153
+ zip?: string;
154
+ trains: string[]; // trainIDs currently associated with this station
155
+ }
156
+
157
+ type TrainsMap = Record<string, Train[]>;
158
+ type StationsMap = Record<string, Station>;
159
+
160
+ // ---------------------------------------------------------------------------
161
+
162
+ async function amtraker<T>(path: string): Promise<T> {
163
+ const controller = new AbortController();
164
+ const timer = setTimeout(() => controller.abort(), 10_000);
165
+ try {
166
+ const res = await fetch(`${BASE_URL}${path}`, {
167
+ signal: controller.signal,
168
+ headers: { Accept: 'application/json' },
169
+ });
170
+ if (!res.ok) {
171
+ throw new Error(
172
+ `Amtraker API error: HTTP ${res.status} on ${path}. The community mirror (api-v3.amtraker.com) may be briefly down — retry in a minute.`,
173
+ );
174
+ }
175
+ return (await res.json()) as T;
176
+ } catch (err) {
177
+ if (err instanceof Error && err.name === 'AbortError') {
178
+ throw new Error('Amtraker API timed out after 10s (api-v3.amtraker.com). Retry in a minute.');
179
+ }
180
+ throw err;
181
+ } finally {
182
+ clearTimeout(timer);
183
+ }
184
+ }
185
+
186
+ /** Minutes between scheduled and actual/estimated ISO timestamps (positive = late). */
187
+ function delayMinutes(scheduled?: string, actual?: string): number | null {
188
+ if (!scheduled || !actual) return null;
189
+ const s = Date.parse(scheduled);
190
+ const a = Date.parse(actual);
191
+ if (Number.isNaN(s) || Number.isNaN(a)) return null;
192
+ return Math.round((a - s) / 60_000);
193
+ }
194
+
195
+ function timelinessLabel(delay: number | null): string {
196
+ if (delay === null) return 'unknown';
197
+ if (delay <= -6) return `${-delay} min early`;
198
+ if (delay <= 5) return 'on time';
199
+ return `${delay} min late`;
200
+ }
201
+
202
+ /** First stop the train hasn't departed yet — its next station. */
203
+ function nextStop(train: Train): TrainStop | undefined {
204
+ return train.stations.find((s) => s.status !== 'Departed');
205
+ }
206
+
207
+ /** Delay of a train at its next station (the "current" delay). */
208
+ function currentDelay(train: Train): number | null {
209
+ const next = nextStop(train);
210
+ if (!next) return null;
211
+ return delayMinutes(next.schArr || next.schDep, next.arr || next.dep);
212
+ }
213
+
214
+ function shapeInstance(t: Train) {
215
+ const next = nextStop(t);
216
+ const first = t.stations[0];
217
+ const last = t.stations[t.stations.length - 1];
218
+ const nextDelay = next ? delayMinutes(next.schArr || next.schDep, next.arr || next.dep) : null;
219
+ const destDelay = last ? delayMinutes(last.schArr, last.arr) : null;
220
+ return {
221
+ train_id: t.trainID,
222
+ train_num: t.trainNum,
223
+ route_name: t.routeName,
224
+ provider: t.provider,
225
+ state: t.trainState,
226
+ position: { lat: t.lat, lon: t.lon },
227
+ speed_mph: Math.round(t.velocity * 10) / 10,
228
+ heading: t.heading,
229
+ timeliness: timelinessLabel(nextDelay),
230
+ status_msg: t.statusMsg?.trim() || undefined,
231
+ last_updated: t.lastValTS,
232
+ origin: first
233
+ ? {
234
+ name: first.name,
235
+ code: first.code,
236
+ scheduled_departure: first.schDep,
237
+ actual_departure: first.status === 'Departed' ? first.dep : null,
238
+ }
239
+ : { name: t.origName, code: t.origCode },
240
+ destination: last
241
+ ? {
242
+ name: last.name,
243
+ code: last.code,
244
+ scheduled_arrival: last.schArr,
245
+ estimated_arrival: last.arr,
246
+ delay_minutes: destDelay,
247
+ }
248
+ : { name: t.destName, code: t.destCode },
249
+ next_station: next
250
+ ? {
251
+ name: next.name,
252
+ code: next.code,
253
+ status: next.status,
254
+ scheduled_arrival: next.schArr,
255
+ estimated_arrival: next.arr,
256
+ delay_minutes: nextDelay,
257
+ }
258
+ : null,
259
+ stops_remaining: t.stations.filter((s) => s.status !== 'Departed').length,
260
+ };
261
+ }
262
+
263
+ const MIRROR_NOTE =
264
+ 'Community mirror of Amtrak tracking (api-v3.amtraker.com); positions update ~every few minutes. Times are local to each station.';
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // amtrak_train_status
268
+
269
+ async function trainStatus(args: Record<string, unknown>) {
270
+ const query = String(args.train ?? args.train_number ?? args.route ?? '').trim();
271
+ if (!query) {
272
+ throw new Error('amtrak_train_status requires a train number or route name, e.g. { train: "6" } or { train: "Coast Starlight" }.');
273
+ }
274
+
275
+ let instances: Train[] = [];
276
+ let matchedBy = '';
277
+
278
+ // Train-number-shaped input ("6", "2150", also "v1"/"b5151" for Via/Brightline)
279
+ if (/^[bv]?\d{1,5}$/i.test(query)) {
280
+ const data = await amtraker<TrainsMap | Train[]>(`/trains/${encodeURIComponent(query.toLowerCase())}`);
281
+ if (!Array.isArray(data)) {
282
+ instances = Object.values(data).flat();
283
+ matchedBy = 'train number';
284
+ }
285
+ // unknown numbers return [] with HTTP 200 — fall through to name matching
286
+ }
287
+
288
+ if (instances.length === 0) {
289
+ const all = await amtraker<TrainsMap>('/trains');
290
+ const q = query.toLowerCase();
291
+ instances = Object.values(all)
292
+ .flat()
293
+ .filter((t) => t.routeName.toLowerCase().includes(q));
294
+ matchedBy = 'route name';
295
+ if (instances.length === 0) {
296
+ const routes = [...new Set(Object.values(all).flat().map((t) => t.routeName))].sort();
297
+ return {
298
+ query,
299
+ count: 0,
300
+ instances: [],
301
+ note: `No active train matches "${query}". It may not be running right now (Predeparture trains appear ~hours before departure). Active routes: ${routes.join(', ')}.`,
302
+ };
303
+ }
304
+ }
305
+
306
+ return {
307
+ query,
308
+ matched_by: matchedBy,
309
+ count: instances.length,
310
+ instances: instances.slice(0, 10).map(shapeInstance),
311
+ note: MIRROR_NOTE,
312
+ };
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Station resolution (shared by station_board and station_info)
317
+
318
+ function stationLabel(s: Station): string {
319
+ return s.name ?? s.city ?? s.code;
320
+ }
321
+
322
+ function resolveStation(stations: StationsMap, query: string): { station?: Station; alternates?: string[] } {
323
+ const code = query.toUpperCase();
324
+ if (stations[code]) return { station: stations[code] };
325
+ const q = query.toLowerCase();
326
+ const all = Object.values(stations);
327
+ const scored = all
328
+ .map((s) => {
329
+ // A handful of entries (mostly bus stops) have no `name` field — fall back to city.
330
+ const name = (s.name ?? s.city ?? '').toLowerCase();
331
+ const city = (s.city ?? '').toLowerCase();
332
+ let score = 0;
333
+ if (name === q) score = 6;
334
+ else if (name.startsWith(q)) score = 5;
335
+ else if (name.includes(q)) score = 4;
336
+ else if (city === q) score = 3;
337
+ else if (city.startsWith(q)) score = 2;
338
+ else if (city.includes(q)) score = 1;
339
+ return { s, score };
340
+ })
341
+ .filter((x) => x.score > 0)
342
+ .sort(
343
+ (a, b) =>
344
+ b.score - a.score ||
345
+ (b.s.trains?.length ?? 0) - (a.s.trains?.length ?? 0) ||
346
+ stationLabel(a.s).localeCompare(stationLabel(b.s)),
347
+ );
348
+ if (scored.length === 0) return {};
349
+ return {
350
+ station: scored[0].s,
351
+ alternates: scored.slice(1, 6).map((x) => `${stationLabel(x.s)} (${x.s.code})`),
352
+ };
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // amtrak_station_board
357
+
358
+ async function stationBoard(args: Record<string, unknown>) {
359
+ const query = String(args.station ?? args.code ?? '').trim();
360
+ if (!query) {
361
+ throw new Error('amtrak_station_board requires a station code or name, e.g. { station: "CHI" }.');
362
+ }
363
+ const limit = Math.min(Math.max(Number(args.limit) || 15, 1), 15);
364
+
365
+ const [stations, trains] = await Promise.all([
366
+ amtraker<StationsMap>('/stations'),
367
+ amtraker<TrainsMap>('/trains'),
368
+ ]);
369
+
370
+ const { station, alternates } = resolveStation(stations, query);
371
+ if (!station) {
372
+ throw new Error(
373
+ `No Amtrak station matches "${query}". Use a 3-letter code (e.g. CHI, NYP, LAX) or a station/city name.`,
374
+ );
375
+ }
376
+
377
+ const inbound: Array<Record<string, unknown> & { _eta: number }> = [];
378
+ const staleCutoff = Date.now() - 60 * 60_000; // drop ghost instances whose ETA passed >1h ago
379
+ for (const instances of Object.values(trains)) {
380
+ for (const t of instances) {
381
+ if (t.trainState === 'Completed') continue;
382
+ const stop = t.stations.find((s) => s.code === station.code);
383
+ if (!stop || stop.status === 'Departed') continue;
384
+ const eta = Date.parse(stop.arr || stop.schArr || stop.schDep);
385
+ if (!Number.isNaN(eta) && eta < staleCutoff) continue;
386
+ const delay = delayMinutes(stop.schArr || stop.schDep, stop.arr || stop.dep);
387
+ inbound.push({
388
+ train_num: t.trainNum,
389
+ train_id: t.trainID,
390
+ route_name: t.routeName,
391
+ provider: t.provider !== 'Amtrak' ? t.provider : undefined,
392
+ origin: `${t.origName} (${t.origCode})`,
393
+ destination: `${t.destName} (${t.destCode})`,
394
+ scheduled_arrival: stop.schArr,
395
+ estimated_arrival: stop.arr,
396
+ delay_minutes: delay,
397
+ timeliness: timelinessLabel(delay),
398
+ train_status: t.trainState,
399
+ _eta: Number.isNaN(eta) ? Number.MAX_SAFE_INTEGER : eta,
400
+ });
401
+ }
402
+ }
403
+ inbound.sort((a, b) => a._eta - b._eta);
404
+
405
+ return {
406
+ station: { name: stationLabel(station), code: station.code, city: station.city, state: station.state, tz: station.tz },
407
+ similar_stations: alternates?.length ? alternates : undefined,
408
+ inbound_count: inbound.length,
409
+ trains: inbound.slice(0, limit).map(({ _eta, ...rest }) => rest),
410
+ note: `Trains currently en route to ${stationLabel(station)}. ${MIRROR_NOTE}`,
411
+ };
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // amtrak_routes_active
416
+
417
+ async function routesActive() {
418
+ const trains = await amtraker<TrainsMap>('/trains');
419
+ const byRoute = new Map<string, { nums: string[]; worst: number | null; provider: string }>();
420
+ let total = 0;
421
+ for (const instances of Object.values(trains)) {
422
+ for (const t of instances) {
423
+ if (t.trainState === 'Completed') continue;
424
+ total++;
425
+ const entry = byRoute.get(t.routeName) ?? { nums: [], worst: null, provider: t.provider };
426
+ entry.nums.push(t.trainNum);
427
+ const delay = currentDelay(t);
428
+ if (delay !== null && (entry.worst === null || delay > entry.worst)) entry.worst = delay;
429
+ byRoute.set(t.routeName, entry);
430
+ }
431
+ }
432
+ const routes = [...byRoute.entries()]
433
+ .map(([route_name, e]) => ({
434
+ route_name,
435
+ provider: e.provider !== 'Amtrak' ? e.provider : undefined,
436
+ active_trains: e.nums.length,
437
+ train_numbers: [...new Set(e.nums)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
438
+ worst_delay_minutes: e.worst,
439
+ }))
440
+ .sort((a, b) => b.active_trains - a.active_trains || a.route_name.localeCompare(b.route_name));
441
+
442
+ return {
443
+ total_active_trains: total,
444
+ route_count: routes.length,
445
+ routes,
446
+ note: `Includes Via Rail and Brightline trains carried in the same feed (marked by provider). ${MIRROR_NOTE}`,
447
+ };
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // amtrak_station_info
452
+
453
+ async function stationInfo(args: Record<string, unknown>) {
454
+ const query = String(args.station ?? args.code ?? '').trim();
455
+ if (!query) {
456
+ throw new Error('amtrak_station_info requires a station code or name, e.g. { station: "Denver" }.');
457
+ }
458
+
459
+ const [stations, trains] = await Promise.all([
460
+ amtraker<StationsMap>('/stations'),
461
+ amtraker<TrainsMap>('/trains'),
462
+ ]);
463
+
464
+ const { station, alternates } = resolveStation(stations, query);
465
+ if (!station) {
466
+ throw new Error(
467
+ `No Amtrak station matches "${query}". Use a 3-letter code (e.g. DEN, NYP) or a station/city name.`,
468
+ );
469
+ }
470
+
471
+ let inboundCount = 0;
472
+ for (const instances of Object.values(trains)) {
473
+ for (const t of instances) {
474
+ if (t.trainState === 'Completed') continue;
475
+ const stop = t.stations.find((s) => s.code === station.code);
476
+ if (stop && stop.status !== 'Departed') inboundCount++;
477
+ }
478
+ }
479
+
480
+ const address = [station.address1?.trim(), station.address2?.trim(), station.city, station.state, station.zip]
481
+ .filter(Boolean)
482
+ .join(', ');
483
+
484
+ return {
485
+ name: stationLabel(station),
486
+ code: station.code,
487
+ city: station.city,
488
+ state: station.state,
489
+ address: address || undefined,
490
+ timezone: station.tz,
491
+ position: { lat: station.lat, lon: station.lon },
492
+ trains_inbound: inboundCount,
493
+ similar_stations: alternates?.length ? alternates : undefined,
494
+ note: MIRROR_NOTE,
495
+ };
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+
500
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
501
+ switch (name) {
502
+ case 'amtrak_train_status':
503
+ return trainStatus(args);
504
+ case 'amtrak_station_board':
505
+ return stationBoard(args);
506
+ case 'amtrak_routes_active':
507
+ return routesActive();
508
+ case 'amtrak_station_info':
509
+ return stationInfo(args);
510
+ default:
511
+ throw new Error(`Unknown tool: ${name}`);
512
+ }
513
+ }
514
+
515
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }