@pipeworx/mcp-cta 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-cta
2
+
3
+ CTA MCP — Chicago Transit Authority real-time trains ('L') + buses
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1331+ 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
+ "cta": {
20
+ "url": "https://gateway.pipeworx.io/cta/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 1331+ 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 Cta 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-cta",
3
+ "version": "0.1.0",
4
+ "description": "CTA MCP — Chicago Transit Authority real-time trains ('L') + buses",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "cta"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-cta"
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/cta",
4
+ "title": "Cta",
5
+ "description": "CTA MCP — Chicago Transit Authority real-time trains ('L') + buses",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/cta",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-cta",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/cta/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,826 @@
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
+ * CTA MCP — Chicago Transit Authority real-time trains ('L') + buses
21
+ *
22
+ * Tools:
23
+ * - cta_train_arrivals: upcoming 'L' trains at a station (Train Tracker ttarrivals)
24
+ * - cta_train_positions: live trains on one or more lines (ttpositions)
25
+ * - cta_bus_predictions: bus arrival predictions at a stop (Bus Tracker getpredictions),
26
+ * with a find_stop text lookup (getdirections + getstops) when the stop id is unknown
27
+ * - cta_bus_positions: live buses on a route (getvehicles)
28
+ *
29
+ * Auth: combined credential. `_apiKey` = "train_key:bus_key" (split on the FIRST
30
+ * colon) — Train Tracker key from transitchicago.com/developers, Bus Tracker key
31
+ * from ctabustracker.com. The gateway injects PLATFORM_CTA_KEY.
32
+ *
33
+ * API quirks:
34
+ * - Train times (arrT/prdt) are "yyyy-MM-ddTHH:mm:ss" wall-clock in America/Chicago
35
+ * with no offset — minutes_away is computed against Chicago "now" via Intl.
36
+ * - Train errors come back HTTP 200 as ctatt.errCd != "0" with errNm text.
37
+ * - Bus timestamps are "yyyyMMdd HH:mm" (also Chicago local); predictions carry a
38
+ * ready-made countdown `prdctdn` ("3", or "DUE" when imminent, "DLY" when delayed).
39
+ * - Bus errors come back HTTP 200 as bustime-response.error[].msg (e.g. "No service
40
+ * scheduled", "No arrival times", "Invalid API access key supplied"). No-data
41
+ * messages are surfaced as a note; auth failures throw.
42
+ * - Station names are ambiguous ("Western" exists on 5 lines / 5 stations) — the
43
+ * embedded table (144 stations from the City of Chicago "CTA L System Information"
44
+ * dataset, resource 8pix-ypme, fetched 2026-07-19) disambiguates via the route
45
+ * filter, else errors with the candidates.
46
+ */
47
+
48
+
49
+ const TRAIN_BASE = 'https://lapi.transitchicago.com/api/1.0';
50
+ const BUS_BASE = 'https://www.ctabustracker.com/bustime/api/v2';
51
+ const TIMEOUT_MS = 8000;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Embedded 'L' station table: [mapid, name, lines]
55
+ // Source: data.cityofchicago.org resource 8pix-ypme (302 stop rows → 144 stations),
56
+ // embedded at build time so station names resolve without a runtime dependency.
57
+
58
+ const STATIONS: Array<[string, string, string]> = [
59
+ ['40830', '18th', 'Pink'],
60
+ ['41120', '35th-Bronzeville-IIT', 'G'],
61
+ ['40120', '35th/Archer', 'Org'],
62
+ ['41270', '43rd', 'G'],
63
+ ['41080', '47th', 'G'],
64
+ ['41230', '47th', 'Red'],
65
+ ['40130', '51st', 'G'],
66
+ ['40580', '54th/Cermak', 'Pink'],
67
+ ['40910', '63rd', 'Red'],
68
+ ['40990', '69th', 'Red'],
69
+ ['40240', '79th', 'Red'],
70
+ ['41430', '87th', 'Red'],
71
+ ['40450', '95th/Dan Ryan', 'Red'],
72
+ ['40680', 'Adams/Wabash', 'Brn,G,Org,P,Pink'],
73
+ ['41420', 'Addison', 'Red'],
74
+ ['41440', 'Addison', 'Brn'],
75
+ ['41240', 'Addison', 'Blue'],
76
+ ['41200', 'Argyle', 'Red'],
77
+ ['40660', 'Armitage', 'Brn,P'],
78
+ ['40170', 'Ashland', 'G,Pink'],
79
+ ['41060', 'Ashland', 'Org'],
80
+ ['40290', 'Ashland/63rd', 'G'],
81
+ ['40010', 'Austin', 'Blue'],
82
+ ['41260', 'Austin', 'G'],
83
+ ['40060', 'Belmont', 'Blue'],
84
+ ['41320', 'Belmont', 'Red,Brn,P'],
85
+ ['40340', 'Berwyn', 'Red'],
86
+ ['41380', 'Bryn Mawr', 'Red'],
87
+ ['41360', 'California', 'G'],
88
+ ['40570', 'California', 'Blue'],
89
+ ['40440', 'California', 'Pink'],
90
+ ['40280', 'Central', 'G'],
91
+ ['41250', 'Central', 'P'],
92
+ ['40780', 'Central Park', 'Pink'],
93
+ ['41000', 'Cermak-Chinatown', 'Red'],
94
+ ['41690', 'Cermak-McCormick Place', 'G'],
95
+ ['41450', 'Chicago', 'Red'],
96
+ ['40710', 'Chicago', 'Brn,P'],
97
+ ['41410', 'Chicago', 'Blue'],
98
+ ['40970', 'Cicero', 'Blue'],
99
+ ['40480', 'Cicero', 'G'],
100
+ ['40420', 'Cicero', 'Pink'],
101
+ ['40630', 'Clark/Division', 'Red'],
102
+ ['40380', 'Clark/Lake', 'Blue,Brn,G,Org,P,Pink'],
103
+ ['41160', 'Clinton', 'G,Pink'],
104
+ ['40430', 'Clinton', 'Blue'],
105
+ ['41670', 'Conservatory', 'G'],
106
+ ['40720', 'Cottage Grove', 'G'],
107
+ ['40230', 'Cumberland', 'Blue'],
108
+ ['40590', 'Damen', 'Blue'],
109
+ ['41710', 'Damen', 'G'],
110
+ ['40210', 'Damen', 'Pink'],
111
+ ['40090', 'Damen', 'Brn'],
112
+ ['40050', 'Davis', 'P'],
113
+ ['40690', 'Dempster', 'P'],
114
+ ['40140', 'Dempster-Skokie', 'Y'],
115
+ ['40530', 'Diversey', 'Brn,P'],
116
+ ['40320', 'Division', 'Blue'],
117
+ ['40390', 'Forest Park', 'Blue'],
118
+ ['40520', 'Foster', 'P'],
119
+ ['40870', 'Francisco', 'Brn'],
120
+ ['41220', 'Fullerton', 'Red,Brn,P'],
121
+ ['41170', 'Garfield', 'Red'],
122
+ ['40510', 'Garfield', 'G'],
123
+ ['40330', 'Grand', 'Red'],
124
+ ['40490', 'Grand', 'Blue'],
125
+ ['40760', 'Granville', 'Red'],
126
+ ['40940', 'Halsted', 'G'],
127
+ ['41130', 'Halsted', 'Org'],
128
+ ['40980', 'Harlem', 'Blue'],
129
+ ['40750', 'Harlem', 'Blue'],
130
+ ['40020', 'Harlem/Lake', 'G'],
131
+ ['40850', 'Harold Washington Library-State/Van Buren', 'Brn,Org,P,Pink'],
132
+ ['41490', 'Harrison', 'Red'],
133
+ ['40900', 'Howard', 'Red,P,Y'],
134
+ ['40810', 'Illinois Medical District', 'Blue'],
135
+ ['40300', 'Indiana', 'G'],
136
+ ['40550', 'Irving Park', 'Blue'],
137
+ ['41460', 'Irving Park', 'Brn'],
138
+ ['40070', 'Jackson', 'Blue'],
139
+ ['40560', 'Jackson', 'Red'],
140
+ ['41190', 'Jarvis', 'Red'],
141
+ ['41280', 'Jefferson Park', 'Blue'],
142
+ ['41150', 'Kedzie', 'Org'],
143
+ ['41070', 'Kedzie', 'G'],
144
+ ['41040', 'Kedzie', 'Pink'],
145
+ ['41180', 'Kedzie', 'Brn'],
146
+ ['40250', 'Kedzie-Homan', 'Blue'],
147
+ ['41290', 'Kimball', 'Brn'],
148
+ ['41140', 'King Drive', 'G'],
149
+ ['40600', 'Kostner', 'Pink'],
150
+ ['41660', 'Lake', 'Red'],
151
+ ['40700', 'Laramie', 'G'],
152
+ ['41340', 'LaSalle', 'Blue'],
153
+ ['40160', 'LaSalle/Van Buren', 'Brn,Org,P,Pink'],
154
+ ['40770', 'Lawrence', 'Red'],
155
+ ['41050', 'Linden', 'P'],
156
+ ['41020', 'Logan Square', 'Blue'],
157
+ ['41300', 'Loyola', 'Red'],
158
+ ['40270', 'Main', 'P'],
159
+ ['40460', 'Merchandise Mart', 'Brn,P'],
160
+ ['40930', 'Midway', 'Org'],
161
+ ['41090', 'Monroe', 'Red'],
162
+ ['40790', 'Monroe', 'Blue'],
163
+ ['41330', 'Montrose', 'Blue'],
164
+ ['41500', 'Montrose', 'Brn'],
165
+ ['41510', 'Morgan', 'G,Pink'],
166
+ ['40100', 'Morse', 'Red'],
167
+ ['40650', 'North/Clybourn', 'Red'],
168
+ ['40400', 'Noyes', 'P'],
169
+ ['40890', 'O\'Hare', 'Blue'],
170
+ ['41350', 'Oak Park', 'G'],
171
+ ['40180', 'Oak Park', 'Blue'],
172
+ ['41680', 'Oakton-Skokie', 'Y'],
173
+ ['41310', 'Paulina', 'Brn'],
174
+ ['41030', 'Polk', 'Pink'],
175
+ ['40030', 'Pulaski', 'G'],
176
+ ['40920', 'Pulaski', 'Blue'],
177
+ ['40150', 'Pulaski', 'Pink'],
178
+ ['40960', 'Pulaski', 'Org'],
179
+ ['40040', 'Quincy/Wells', 'Brn,Org,P,Pink'],
180
+ ['40470', 'Racine', 'Blue'],
181
+ ['40610', 'Ridgeland', 'G'],
182
+ ['41010', 'Rockwell', 'Brn'],
183
+ ['41400', 'Roosevelt', 'Red,G,Org'],
184
+ ['40820', 'Rosemont', 'Blue'],
185
+ ['40800', 'Sedgwick', 'Brn,P'],
186
+ ['40080', 'Sheridan', 'Red'],
187
+ ['40840', 'South Boulevard', 'P'],
188
+ ['40360', 'Southport', 'Brn'],
189
+ ['40190', 'Sox-35th', 'Red'],
190
+ ['40260', 'State/Lake', 'Brn,G,Org,P,Pink'],
191
+ ['40880', 'Thorndale', 'Red'],
192
+ ['40350', 'UIC-Halsted', 'Blue'],
193
+ ['40370', 'Washington', 'Blue'],
194
+ ['41700', 'Washington/Wabash', 'Brn,G,Org,P,Pink'],
195
+ ['40730', 'Washington/Wells', 'Brn,Org,P,Pink'],
196
+ ['41210', 'Wellington', 'Brn,P'],
197
+ ['41480', 'Western', 'Brn'],
198
+ ['40670', 'Western', 'Blue'],
199
+ ['40220', 'Western', 'Blue'],
200
+ ['40310', 'Western', 'Org'],
201
+ ['40740', 'Western', 'Pink'],
202
+ ['40540', 'Wilson', 'Red,P'],
203
+ ];
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Line-name normalization: full color names → Train Tracker route codes
207
+
208
+ const LINE_CODES: Record<string, string> = {
209
+ red: 'Red',
210
+ blue: 'Blue',
211
+ brown: 'Brn',
212
+ brn: 'Brn',
213
+ green: 'G',
214
+ g: 'G',
215
+ orange: 'Org',
216
+ org: 'Org',
217
+ purple: 'P',
218
+ p: 'P',
219
+ pink: 'Pink',
220
+ pnk: 'Pink',
221
+ yellow: 'Y',
222
+ y: 'Y',
223
+ };
224
+
225
+ const LINE_LABELS: Record<string, string> = {
226
+ Red: 'Red',
227
+ Blue: 'Blue',
228
+ Brn: 'Brown',
229
+ G: 'Green',
230
+ Org: 'Orange',
231
+ P: 'Purple',
232
+ Pink: 'Pink',
233
+ Y: 'Yellow',
234
+ };
235
+
236
+ function toLineCode(input: string): string {
237
+ const key = input.trim().toLowerCase().replace(/\s*line$/, '');
238
+ const code = LINE_CODES[key];
239
+ if (!code) {
240
+ throw new Error(
241
+ `CTA: unknown 'L' line "${input}". Valid lines: Red, Blue, Brown, Green, Orange, Purple, Pink, Yellow.`,
242
+ );
243
+ }
244
+ return code;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Station name resolution
249
+
250
+ /** Normalize a station name/query: lowercase, drop "and"/"&"/"/" and punctuation. */
251
+ function normName(s: string): string {
252
+ return s
253
+ .toLowerCase()
254
+ .replace(/\s+(station|stop)\s*$/, '')
255
+ .replace(/\band\b/g, '')
256
+ .replace(/[^a-z0-9]+/g, '');
257
+ }
258
+
259
+ interface Station {
260
+ mapid: string;
261
+ name: string;
262
+ lines: string[];
263
+ }
264
+
265
+ function stationList(): Station[] {
266
+ return STATIONS.map(([mapid, name, lines]) => ({ mapid, name, lines: lines.split(',') }));
267
+ }
268
+
269
+ /** Resolve station name or mapid → station(s); routeCode disambiguates duplicates. */
270
+ function resolveStation(input: string, routeCode?: string): Station {
271
+ const raw = String(input ?? '').trim();
272
+ if (!raw) {
273
+ throw new Error('CTA: station is required — pass a station name like "Belmont" or "Clark/Lake", or a numeric mapid like 41320.');
274
+ }
275
+ if (/^4\d{4}$/.test(raw)) {
276
+ const known = stationList().find((s) => s.mapid === raw);
277
+ return known ?? { mapid: raw, name: raw, lines: [] };
278
+ }
279
+
280
+ const q = normName(raw);
281
+ if (!q) throw new Error(`CTA: could not parse station "${input}".`);
282
+ const all = stationList();
283
+
284
+ let matches = all.filter((s) => normName(s.name) === q);
285
+ if (matches.length === 0) matches = all.filter((s) => normName(s.name).startsWith(q));
286
+ if (matches.length === 0) matches = all.filter((s) => normName(s.name).includes(q));
287
+ if (matches.length === 0) {
288
+ throw new Error(
289
+ `CTA: no 'L' station matched "${input}". Pass a station name (e.g. "Belmont", "Clark/Lake", "O'Hare") or a 5-digit mapid. For bus stops use cta_bus_predictions instead.`,
290
+ );
291
+ }
292
+
293
+ if (matches.length > 1 && routeCode) {
294
+ const onLine = matches.filter((s) => s.lines.includes(routeCode));
295
+ if (onLine.length >= 1) matches = onLine;
296
+ }
297
+ if (matches.length > 1) {
298
+ // Distinct stations sharing a name (e.g. Western ×5). Same name+lines → take first.
299
+ const opts = matches
300
+ .map((s) => `${s.name} (mapid ${s.mapid}, ${s.lines.map((l) => LINE_LABELS[l] ?? l).join('/')} Line)`)
301
+ .join('; ');
302
+ throw new Error(
303
+ `CTA: "${input}" matches multiple 'L' stations: ${opts}. Pass the mapid, or add a route (line color) to disambiguate.`,
304
+ );
305
+ }
306
+ return matches[0];
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Time helpers — Train Tracker times are Chicago wall-clock with no UTC offset.
311
+
312
+ /** Chicago "now" as a pseudo-UTC epoch, comparable with Date.parse(wallTime + 'Z'). */
313
+ function chicagoNowMs(): number {
314
+ const parts = new Intl.DateTimeFormat('en-CA', {
315
+ timeZone: 'America/Chicago',
316
+ year: 'numeric',
317
+ month: '2-digit',
318
+ day: '2-digit',
319
+ hour: '2-digit',
320
+ minute: '2-digit',
321
+ second: '2-digit',
322
+ hour12: false,
323
+ }).formatToParts(new Date());
324
+ const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '00';
325
+ const h = get('hour') === '24' ? '00' : get('hour');
326
+ return Date.parse(`${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}:${get('second')}Z`);
327
+ }
328
+
329
+ /** Minutes from Chicago-now until a "yyyy-MM-ddTHH:mm:ss" Chicago wall time. */
330
+ function minutesAway(wallTime: string): number | null {
331
+ const t = Date.parse(`${wallTime}Z`);
332
+ if (Number.isNaN(t)) return null;
333
+ return Math.max(0, Math.round((t - chicagoNowMs()) / 60000));
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Credential + fetch plumbing
338
+
339
+ function splitKeys(args: Record<string, unknown>): { trainKey: string; busKey: string } {
340
+ const combined = typeof args._apiKey === 'string' ? args._apiKey.trim() : '';
341
+ delete args._apiKey;
342
+ const idx = combined.indexOf(':');
343
+ const trainKey = idx > 0 ? combined.slice(0, idx).trim() : '';
344
+ const busKey = idx > 0 ? combined.slice(idx + 1).trim() : '';
345
+ if (!trainKey || !busKey) {
346
+ throw new Error(
347
+ 'CTA requires a combined credential as "train_key:bus_key" — a Train Tracker API key (free signup at transitchicago.com/developers) and a Bus Tracker API key (free signup at ctabustracker.com), joined by a colon. Pass it via _apiKey, or [sign up](https://pipeworx.io/signup?via=auth_hint) to use the platform credentials.',
348
+ );
349
+ }
350
+ return { trainKey, busKey };
351
+ }
352
+
353
+ async function timedFetch(url: string): Promise<Response> {
354
+ const ctrl = new AbortController();
355
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
356
+ try {
357
+ return await fetch(url, { signal: ctrl.signal });
358
+ } catch (e) {
359
+ if ((e as Error).name === 'AbortError') {
360
+ throw new Error(`CTA API timeout after ${TIMEOUT_MS / 1000}s — the upstream tracker is slow right now, retry shortly.`);
361
+ }
362
+ throw e;
363
+ } finally {
364
+ clearTimeout(timer);
365
+ }
366
+ }
367
+
368
+ // One item vs array is inconsistent in the Train Tracker JSON — normalize.
369
+ function toArray<T>(v: T | T[] | undefined | null): T[] {
370
+ if (v === undefined || v === null) return [];
371
+ return Array.isArray(v) ? v : [v];
372
+ }
373
+
374
+ interface Ctatt {
375
+ tmst?: string;
376
+ errCd?: string;
377
+ errNm?: string | null;
378
+ [k: string]: unknown;
379
+ }
380
+
381
+ async function trainApi(endpoint: string, params: Record<string, string>, trainKey: string): Promise<Ctatt> {
382
+ const qs = new URLSearchParams({ key: trainKey, ...params, outputType: 'JSON' });
383
+ const res = await timedFetch(`${TRAIN_BASE}/${endpoint}?${qs}`);
384
+ if (!res.ok) throw new Error(`CTA Train Tracker: HTTP ${res.status}`);
385
+ const data = (await res.json()) as { ctatt?: Ctatt };
386
+ const ctatt = data.ctatt ?? {};
387
+ if (ctatt.errCd && ctatt.errCd !== '0') {
388
+ const nm = ctatt.errNm ?? `error ${ctatt.errCd}`;
389
+ if (/api key/i.test(String(nm))) {
390
+ throw new Error(
391
+ `CTA Train Tracker: ${nm}. The train half of the "train_key:bus_key" credential is invalid — free keys at transitchicago.com/developers.`,
392
+ );
393
+ }
394
+ throw new Error(`CTA Train Tracker: ${nm}`);
395
+ }
396
+ return ctatt;
397
+ }
398
+
399
+ interface BustimeResponse {
400
+ error?: Array<{ msg?: string; rt?: string; stpid?: string }>;
401
+ [k: string]: unknown;
402
+ }
403
+
404
+ async function busApi(endpoint: string, params: Record<string, string>, busKey: string): Promise<BustimeResponse> {
405
+ const qs = new URLSearchParams({ key: busKey, ...params, format: 'json' });
406
+ const res = await timedFetch(`${BUS_BASE}/${endpoint}?${qs}`);
407
+ if (!res.ok) throw new Error(`CTA Bus Tracker: HTTP ${res.status}`);
408
+ const data = (await res.json()) as { 'bustime-response'?: BustimeResponse };
409
+ const body = data['bustime-response'] ?? {};
410
+ const errs = body.error ?? [];
411
+ if (errs.some((e) => /api access key/i.test(e.msg ?? ''))) {
412
+ throw new Error(
413
+ 'CTA Bus Tracker: invalid API key. The bus half of the "train_key:bus_key" credential is invalid — free keys at ctabustracker.com.',
414
+ );
415
+ }
416
+ return body;
417
+ }
418
+
419
+ /** Human-readable summary of bustime-response.error[] (no-data style messages). */
420
+ function busErrorNote(body: BustimeResponse): string | undefined {
421
+ const errs = body.error ?? [];
422
+ if (errs.length === 0) return undefined;
423
+ return errs
424
+ .map((e) => `${e.msg ?? 'error'}${e.stpid ? ` (stop ${e.stpid})` : e.rt ? ` (route ${e.rt})` : ''}`)
425
+ .join('; ');
426
+ }
427
+
428
+ function clampNum(v: unknown, def: number, min: number, max: number): number {
429
+ const n = Number(v);
430
+ return Number.isFinite(n) ? Math.min(Math.max(Math.trunc(n), min), max) : def;
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Tool definitions
435
+
436
+ const KEY_DESC =
437
+ 'Optional: your own CTA credentials as "train_key:bus_key" — Train Tracker key (free at transitchicago.com/developers) + Bus Tracker key (free at ctabustracker.com)';
438
+
439
+ const tools: McpToolExport['tools'] = [
440
+ {
441
+ name: 'cta_train_arrivals',
442
+ description:
443
+ 'Real-time Chicago CTA \'L\' train arrivals at a station — answers "when is the next train Chicago", next Red Line at Belmont, Blue Line to O\'Hare from Clark/Lake. Returns each upcoming train\'s line color, destination, arrival time and minutes_away, plus approaching / delayed / scheduled-only flags and the platform description. Station accepts a name ("Belmont", "Clark/Lake", "O\'Hare") or a 5-digit mapid (e.g. 41320). Example: cta_train_arrivals({ station: "Belmont", route: "Red" })',
444
+ inputSchema: {
445
+ type: 'object' as const,
446
+ properties: {
447
+ station: {
448
+ type: 'string',
449
+ description: 'Station name (e.g. "Belmont", "Clark/Lake", "O\'Hare", "Midway") or 5-digit mapid (e.g. "41320")',
450
+ },
451
+ route: {
452
+ type: 'string',
453
+ description: 'Optional \'L\' line filter: Red, Blue, Brown, Green, Orange, Purple, Pink, or Yellow (also disambiguates same-named stations)',
454
+ },
455
+ max: { type: 'number', description: 'Max arrivals to return, 1-20 (default 8)' },
456
+ _apiKey: { type: 'string', description: KEY_DESC },
457
+ },
458
+ required: ['station'],
459
+ },
460
+ },
461
+ {
462
+ name: 'cta_train_positions',
463
+ description:
464
+ 'Live Chicago CTA \'L\' train positions on one or more lines — where every Red Line, Blue Line, Brown, Green, Orange, Purple, Pink, or Yellow train is right now: lat/lon, heading, next station with ETA, destination, approaching and delayed flags. Answers "where are the Blue Line trains" for the Chicago L. Example: cta_train_positions({ routes: "Red,Blue" })',
465
+ inputSchema: {
466
+ type: 'object' as const,
467
+ properties: {
468
+ routes: {
469
+ type: 'string',
470
+ description: 'Comma-separated \'L\' lines, full color names accepted: "Red", "Red,Blue", "Brown,Purple"',
471
+ },
472
+ _apiKey: { type: 'string', description: KEY_DESC },
473
+ },
474
+ required: ['routes'],
475
+ },
476
+ },
477
+ {
478
+ name: 'cta_bus_predictions',
479
+ description:
480
+ 'Chicago CTA bus tracker arrival predictions at a bus stop — route, destination, predicted minutes until arrival ("DUE" = arriving now), delay flag, and vehicle id. Pass stop_id (the 4-5 digit stop number posted on CTA bus-stop signs), optionally with route. If you only know the stop by name, pass route + find_stop (a street/intersection fragment like "clark & madison") and the stop is looked up for you. Example: cta_bus_predictions({ route: "22", find_stop: "addison" })',
481
+ inputSchema: {
482
+ type: 'object' as const,
483
+ properties: {
484
+ stop_id: {
485
+ type: 'string',
486
+ description: 'CTA bus stop id (stpid), the number on the bus-stop sign, e.g. "1926". Comma-separable up to 10.',
487
+ },
488
+ route: {
489
+ type: 'string',
490
+ description: 'Optional bus route number to filter, e.g. "22", "66", "X49". Required when using find_stop.',
491
+ },
492
+ find_stop: {
493
+ type: 'string',
494
+ description: 'Stop-name fragment to look up when stop_id is unknown, e.g. "clark & madison", "michigan & randolph". Requires route.',
495
+ },
496
+ direction: {
497
+ type: 'string',
498
+ description: 'Optional direction to narrow find_stop, e.g. "Northbound", "south"',
499
+ },
500
+ max: { type: 'number', description: 'Max predictions to return, 1-20 (default 10)' },
501
+ _apiKey: { type: 'string', description: KEY_DESC },
502
+ },
503
+ },
504
+ },
505
+ {
506
+ name: 'cta_bus_positions',
507
+ description:
508
+ 'Live Chicago CTA bus positions on a route — every vehicle currently running: lat/lon, heading, destination, delayed flag, and distance along the pattern. Answers "where is the 22 Clark bus right now" via the CTA bus tracker. Example: cta_bus_positions({ route: "22" })',
509
+ inputSchema: {
510
+ type: 'object' as const,
511
+ properties: {
512
+ route: { type: 'string', description: 'Bus route number, e.g. "22", "66", "146". Comma-separable up to 10.' },
513
+ _apiKey: { type: 'string', description: KEY_DESC },
514
+ },
515
+ required: ['route'],
516
+ },
517
+ },
518
+ ];
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // cta_train_arrivals
522
+
523
+ interface RawEta {
524
+ staId: string;
525
+ stpId: string;
526
+ staNm: string;
527
+ stpDe: string;
528
+ rn: string;
529
+ rt: string;
530
+ destNm: string;
531
+ prdt: string;
532
+ arrT: string;
533
+ isApp: string;
534
+ isSch: string;
535
+ isDly: string;
536
+ lat?: string | null;
537
+ lon?: string | null;
538
+ }
539
+
540
+ async function trainArrivals(args: Record<string, unknown>, trainKey: string) {
541
+ const routeCode = args.route ? toLineCode(String(args.route)) : undefined;
542
+ const station = resolveStation(String(args.station ?? ''), routeCode);
543
+ const max = clampNum(args.max, 8, 1, 20);
544
+
545
+ const params: Record<string, string> = { mapid: station.mapid, max: String(max) };
546
+ if (routeCode) params.rt = routeCode;
547
+ const ctatt = await trainApi('ttarrivals.aspx', params, trainKey);
548
+
549
+ const etas = toArray(ctatt.eta as RawEta | RawEta[]).map((e) => ({
550
+ line: LINE_LABELS[e.rt] ?? e.rt,
551
+ run_number: e.rn,
552
+ destination: e.destNm,
553
+ platform: e.stpDe, // e.g. "Service toward Loop"
554
+ arrival_time: e.arrT,
555
+ minutes_away: minutesAway(e.arrT),
556
+ approaching: e.isApp === '1',
557
+ delayed: e.isDly === '1',
558
+ scheduled_only: e.isSch === '1', // true = timetable, not a live-tracked train
559
+ }));
560
+
561
+ return {
562
+ station: station.name,
563
+ mapid: station.mapid,
564
+ lines: station.lines.map((l) => LINE_LABELS[l] ?? l),
565
+ generated_at: ctatt.tmst,
566
+ count: etas.length,
567
+ note:
568
+ etas.length === 0
569
+ ? 'No upcoming trains reported for this station right now (service may have ended, or check the route filter).'
570
+ : undefined,
571
+ arrivals: etas,
572
+ };
573
+ }
574
+
575
+ // ---------------------------------------------------------------------------
576
+ // cta_train_positions
577
+
578
+ interface RawTrain {
579
+ rn: string;
580
+ destNm: string;
581
+ nextStaNm: string;
582
+ arrT: string;
583
+ isApp: string;
584
+ isDly: string;
585
+ lat: string;
586
+ lon: string;
587
+ heading: string;
588
+ }
589
+
590
+ async function trainPositions(args: Record<string, unknown>, trainKey: string) {
591
+ const routesInput = String(args.routes ?? args.route ?? '').trim();
592
+ if (!routesInput) {
593
+ throw new Error('cta_train_positions requires routes, e.g. { routes: "Red" } or { routes: "Red,Blue" }.');
594
+ }
595
+ const codes = routesInput.split(',').map(toLineCode);
596
+ const ctatt = await trainApi('ttpositions.aspx', { rt: codes.join(',') }, trainKey);
597
+
598
+ const routes = toArray(ctatt.route as Record<string, unknown> | Record<string, unknown>[]).map((r) => {
599
+ const code = String(r['@name'] ?? r.name ?? '');
600
+ const codeKey = Object.keys(LINE_LABELS).find((k) => k.toLowerCase() === code.toLowerCase()) ?? code;
601
+ const trains = toArray(r.train as RawTrain | RawTrain[]).map((t) => ({
602
+ run_number: t.rn,
603
+ destination: t.destNm,
604
+ next_station: t.nextStaNm,
605
+ next_arrival_time: t.arrT,
606
+ next_arrival_minutes: minutesAway(t.arrT),
607
+ approaching: t.isApp === '1',
608
+ delayed: t.isDly === '1',
609
+ latitude: Number(t.lat),
610
+ longitude: Number(t.lon),
611
+ heading_degrees: Number(t.heading),
612
+ }));
613
+ return { line: LINE_LABELS[codeKey] ?? code, train_count: trains.length, trains };
614
+ });
615
+
616
+ return {
617
+ generated_at: ctatt.tmst,
618
+ routes,
619
+ note: routes.every((r) => r.train_count === 0)
620
+ ? 'No trains currently in service on the requested line(s) — service may have ended for the night.'
621
+ : undefined,
622
+ };
623
+ }
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // cta_bus_predictions
627
+
628
+ interface RawPrd {
629
+ tmstmp: string;
630
+ typ: string;
631
+ stpnm: string;
632
+ stpid: string;
633
+ vid: string;
634
+ rt: string;
635
+ rtdir: string;
636
+ des: string;
637
+ prdtm: string;
638
+ dly: boolean;
639
+ prdctdn: string;
640
+ }
641
+
642
+ interface RawStop {
643
+ stpid: string;
644
+ stpnm: string;
645
+ lat: number;
646
+ lon: number;
647
+ }
648
+
649
+ /** Resolve a stop-name fragment on a route to up to 5 stops via getdirections + getstops. */
650
+ async function findStops(
651
+ route: string,
652
+ fragment: string,
653
+ directionFilter: string | undefined,
654
+ busKey: string,
655
+ ): Promise<Array<RawStop & { direction: string }>> {
656
+ const dirBody = await busApi('getdirections', { rt: route }, busKey);
657
+ const dirErr = busErrorNote(dirBody);
658
+ let dirs = toArray(dirBody.directions as Array<{ dir: string }> | { dir: string }).map((d) => d.dir);
659
+ if (dirs.length === 0) {
660
+ throw new Error(`CTA Bus Tracker: no directions found for route "${route}"${dirErr ? ` — ${dirErr}` : ''}. Check the route number (e.g. "22", "66").`);
661
+ }
662
+ if (directionFilter) {
663
+ const df = directionFilter.trim().toLowerCase();
664
+ const narrowed = dirs.filter((d) => d.toLowerCase().startsWith(df) || df.startsWith(d.toLowerCase().replace(/bound$/, '')));
665
+ if (narrowed.length > 0) dirs = narrowed;
666
+ }
667
+
668
+ const allStops: Array<RawStop & { direction: string; norm: string }> = [];
669
+ for (const dir of dirs) {
670
+ const stopsBody = await busApi('getstops', { rt: route, dir }, busKey);
671
+ for (const s of toArray(stopsBody.stops as RawStop[] | RawStop)) {
672
+ allStops.push({ ...s, direction: dir, norm: normName(s.stpnm) });
673
+ }
674
+ }
675
+
676
+ // Token match: "clark & madison" → [clark, madison]; a stop must contain every
677
+ // token (order-independent, so "madison & clark" works too). If nothing contains
678
+ // all tokens (e.g. the bus runs on Dearborn northbound, so there is no
679
+ // "Clark & Madison"), fall back to the single token with the fewest — most
680
+ // specific — matches ("madison" → "Dearborn & Madison").
681
+ const tokens = fragment
682
+ .toLowerCase()
683
+ .split(/\s*(?:&|\/|,|\band\b)\s*/)
684
+ .map(normName)
685
+ .filter(Boolean);
686
+ const q = normName(fragment);
687
+ let matches: Array<RawStop & { direction: string; norm: string }> =
688
+ tokens.length > 0
689
+ ? allStops.filter((s) => tokens.every((t) => s.norm.includes(t)))
690
+ : allStops.filter((s) => s.norm.includes(q));
691
+ if (matches.length === 0 && tokens.length > 1) {
692
+ let best: typeof matches = [];
693
+ for (const t of tokens) {
694
+ const hits = allStops.filter((s) => s.norm.includes(t));
695
+ if (hits.length > 0 && (best.length === 0 || hits.length < best.length)) best = hits;
696
+ }
697
+ matches = best;
698
+ }
699
+ if (matches.length === 0) {
700
+ throw new Error(
701
+ `CTA Bus Tracker: no stop on route ${route}${directionFilter ? ` (${dirs.join('/')})` : ''} matched "${fragment}". Try a street or intersection fragment like "madison" or "clark & addison", or pass the stop_id from the bus-stop sign.`,
702
+ );
703
+ }
704
+ return matches.slice(0, 5);
705
+ }
706
+
707
+ async function busPredictions(args: Record<string, unknown>, busKey: string) {
708
+ const route = args.route ? String(args.route).trim() : undefined;
709
+ const max = clampNum(args.max, 10, 1, 20);
710
+ let stopIds = args.stop_id ? String(args.stop_id).trim() : '';
711
+ let matchedStops: Array<RawStop & { direction: string }> | undefined;
712
+
713
+ if (!stopIds) {
714
+ const fragment = args.find_stop ? String(args.find_stop).trim() : '';
715
+ if (!fragment) {
716
+ throw new Error(
717
+ 'cta_bus_predictions requires stop_id (the number on the CTA bus-stop sign, e.g. "1926"), or route + find_stop to look a stop up by name, e.g. { route: "22", find_stop: "clark & madison" }.',
718
+ );
719
+ }
720
+ if (!route) {
721
+ throw new Error('cta_bus_predictions: find_stop requires a route, e.g. { route: "22", find_stop: "madison" }.');
722
+ }
723
+ matchedStops = await findStops(route, fragment, args.direction ? String(args.direction) : undefined, busKey);
724
+ stopIds = matchedStops.map((s) => s.stpid).join(',');
725
+ }
726
+
727
+ const params: Record<string, string> = { stpid: stopIds, top: String(max) };
728
+ if (route) params.rt = route;
729
+ const body = await busApi('getpredictions', params, busKey);
730
+ const note = busErrorNote(body);
731
+
732
+ const predictions = toArray(body.prd as RawPrd[] | RawPrd).map((p) => ({
733
+ route: p.rt,
734
+ direction: p.rtdir,
735
+ destination: p.des,
736
+ stop: p.stpnm,
737
+ stop_id: p.stpid,
738
+ type: p.typ === 'D' ? 'departure' : 'arrival',
739
+ predicted_time: p.prdtm, // "yyyyMMdd HH:mm" Chicago local
740
+ minutes: p.prdctdn === 'DUE' ? 0 : p.prdctdn === 'DLY' ? null : Number(p.prdctdn),
741
+ due: p.prdctdn === 'DUE',
742
+ delayed: p.dly === true || p.prdctdn === 'DLY',
743
+ vehicle_id: p.vid,
744
+ }));
745
+
746
+ return {
747
+ stop_ids: stopIds,
748
+ matched_stops: matchedStops?.map((s) => ({
749
+ stop_id: s.stpid,
750
+ name: s.stpnm,
751
+ direction: s.direction,
752
+ latitude: s.lat,
753
+ longitude: s.lon,
754
+ })),
755
+ count: predictions.length,
756
+ note:
757
+ predictions.length === 0
758
+ ? `No predictions right now${note ? ` — ${note}` : ''}. Stop ids are the 4-5 digit numbers on CTA bus-stop signs.`
759
+ : note,
760
+ predictions,
761
+ };
762
+ }
763
+
764
+ // ---------------------------------------------------------------------------
765
+ // cta_bus_positions
766
+
767
+ interface RawVehicle {
768
+ vid: string;
769
+ tmstmp: string;
770
+ lat: string;
771
+ lon: string;
772
+ hdg: string;
773
+ rt: string;
774
+ des: string;
775
+ pdist: number;
776
+ dly: boolean;
777
+ }
778
+
779
+ async function busPositions(args: Record<string, unknown>, busKey: string) {
780
+ const route = String(args.route ?? '').trim();
781
+ if (!route) throw new Error('cta_bus_positions requires a route, e.g. { route: "22" }.');
782
+ const body = await busApi('getvehicles', { rt: route }, busKey);
783
+ const note = busErrorNote(body);
784
+
785
+ const vehicles = toArray(body.vehicle as RawVehicle[] | RawVehicle).map((v) => ({
786
+ vehicle_id: v.vid,
787
+ route: v.rt,
788
+ destination: v.des,
789
+ latitude: Number(v.lat),
790
+ longitude: Number(v.lon),
791
+ heading_degrees: Number(v.hdg),
792
+ pattern_distance_feet: v.pdist,
793
+ delayed: v.dly === true,
794
+ updated_at: v.tmstmp, // "yyyyMMdd HH:mm" Chicago local
795
+ }));
796
+
797
+ return {
798
+ route,
799
+ count: vehicles.length,
800
+ note:
801
+ vehicles.length === 0
802
+ ? `No buses currently tracked on route ${route}${note ? ` — ${note}` : ''}.`
803
+ : note,
804
+ vehicles,
805
+ };
806
+ }
807
+
808
+ // ---------------------------------------------------------------------------
809
+
810
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
811
+ const { trainKey, busKey } = splitKeys(args);
812
+ switch (name) {
813
+ case 'cta_train_arrivals':
814
+ return trainArrivals(args, trainKey);
815
+ case 'cta_train_positions':
816
+ return trainPositions(args, trainKey);
817
+ case 'cta_bus_predictions':
818
+ return busPredictions(args, busKey);
819
+ case 'cta_bus_positions':
820
+ return busPositions(args, busKey);
821
+ default:
822
+ throw new Error(`Unknown tool: ${name}`);
823
+ }
824
+ }
825
+
826
+ 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
+ }