@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 +21 -0
- package/README.md +55 -0
- package/package.json +20 -0
- package/server.json +18 -0
- package/src/index.ts +515 -0
- package/tsconfig.json +14 -0
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
|
+
}
|