@pipeworx/mcp-irail 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-irail
2
+
3
+ iRail MCP — Belgian rail (SNCB/NMBS) real-time via the community iRail API
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1325+ 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
+ "irail": {
20
+ "url": "https://gateway.pipeworx.io/irail/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 1325+ 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 Irail 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-irail",
3
+ "version": "0.1.0",
4
+ "description": "iRail MCP — Belgian rail (SNCB/NMBS) real-time via the community iRail API",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "irail"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-irail"
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/irail",
4
+ "title": "Irail",
5
+ "description": "iRail MCP — Belgian rail (SNCB/NMBS) real-time via the community iRail API",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/irail",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-irail",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/irail/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,560 @@
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
+ * iRail MCP — Belgian rail (SNCB/NMBS) real-time via the community iRail API
21
+ * (https://api.irail.be, keyless, https://docs.irail.be)
22
+ *
23
+ * Tools:
24
+ * - irail_liveboard: departures (or arrivals) board at a Belgian station
25
+ * - irail_journey: journey planner from A to B with legs, transfers, delays
26
+ * - irail_train: one train by id — all stops, scheduled vs actual, delay
27
+ * - irail_disturbances: current network disturbances and planned works
28
+ *
29
+ * API quirks (probed live 2026-07-19):
30
+ * - Every endpoint answers with a 303 redirect to a versioned path; fetch
31
+ * must follow redirects (Workers fetch does by default — do not set
32
+ * redirect: 'manual').
33
+ * - iRail asks for an identifying User-Agent; we send one on every call.
34
+ * - Delays are in SECONDS (delay "1260" = 21 min); timestamps are unix
35
+ * epoch STRINGS. We convert to minutes and Europe/Brussels local ISO.
36
+ * - Station lookup fuzzy-matches but greedily: "antwerp" resolves to
37
+ * Antwerp-Haven and "gent" to Gentbrugge, so major-city inputs are
38
+ * mapped through an alias table to the main station first. English
39
+ * exonyms work ("Brussels-South", "Ghent-Sint-Pieters"). Unknown
40
+ * stations return HTTP 400 with RequestedStopNotFoundException.
41
+ * - Connections date format is DDMMYY, time is HHMM, timesel selects
42
+ * depart/arrive; interpreted in Belgian local time.
43
+ * - platforminfo.normal === "0" means the platform CHANGED from the usual
44
+ * one — surfaced as platform_changed.
45
+ */
46
+
47
+
48
+ const BASE_URL = 'https://api.irail.be';
49
+ const USER_AGENT = 'pipeworx.io (bruce@mojibake.ai)';
50
+ const TIMEOUT_MS = 8000;
51
+
52
+ const tools: McpToolExport['tools'] = [
53
+ {
54
+ name: 'irail_liveboard',
55
+ description:
56
+ 'Live departures board at a Belgian train station — Belgian train times SNCB NMBS. Brussels Antwerp Ghent departures with train number, destination, scheduled time, delay in minutes, platform, and canceled flag. Set type to "arrival" for the arrivals board. Station names accept English exonyms and fuzzy input ("Brussels-South", "Antwerp-Central", "Liège-Guillemins"). Example: irail_liveboard({ station: "Brussels-South" })',
57
+ inputSchema: {
58
+ type: 'object' as const,
59
+ properties: {
60
+ station: {
61
+ type: 'string',
62
+ description:
63
+ 'Belgian station name, e.g. "Brussels-South", "Antwerp-Central", "Ghent-Sint-Pieters", "Leuven", "Bruges"',
64
+ },
65
+ type: {
66
+ type: 'string',
67
+ enum: ['departure', 'arrival'],
68
+ description: 'Board type: "departure" (default) or "arrival"',
69
+ },
70
+ limit: { type: 'number', description: 'Max entries to return, 1-50 (default 15)' },
71
+ },
72
+ required: ['station'],
73
+ },
74
+ },
75
+ {
76
+ name: 'irail_journey',
77
+ description:
78
+ 'Plan a train journey between two Belgian stations — SNCB NMBS route planner with legs, transfers, live delays, and platforms per leg. Answers "next train from Brussels to Antwerp", "how do I get from Ghent to Liège by rail". Optional depart_at or arrive_by as ISO datetime ("2026-07-20T09:00") or time ("09:00"), interpreted in Belgian local time. Example: irail_journey({ from: "Brussels-South", to: "Antwerp-Central" })',
79
+ inputSchema: {
80
+ type: 'object' as const,
81
+ properties: {
82
+ from: { type: 'string', description: 'Origin station, e.g. "Brussels-South"' },
83
+ to: { type: 'string', description: 'Destination station, e.g. "Antwerp-Central"' },
84
+ depart_at: {
85
+ type: 'string',
86
+ description:
87
+ 'Depart at/after this time — "YYYY-MM-DDTHH:MM" or "HH:MM" (today), Belgian local time. Default: now',
88
+ },
89
+ arrive_by: {
90
+ type: 'string',
91
+ description: 'Arrive by this time instead — same formats as depart_at. Overrides depart_at',
92
+ },
93
+ limit: { type: 'number', description: 'Max connections to return, 1-6 (default 4)' },
94
+ },
95
+ required: ['from', 'to'],
96
+ },
97
+ },
98
+ {
99
+ name: 'irail_train',
100
+ description:
101
+ 'Track one Belgian train by its number — is my Belgian train delayed. All stops with scheduled vs actual times, per-stop delay in minutes, platforms, current delay, and live position. Accepts "IC 1832", "IC1832", or "BE.NMBS.IC1832". Train numbers come from irail_liveboard or irail_journey. Example: irail_train({ id: "IC1832" })',
102
+ inputSchema: {
103
+ type: 'object' as const,
104
+ properties: {
105
+ id: {
106
+ type: 'string',
107
+ description: 'Train id, e.g. "IC1832", "IC 538", "S52558", "EUR9381", or "BE.NMBS.IC1832"',
108
+ },
109
+ date: {
110
+ type: 'string',
111
+ description: 'Optional travel date "YYYY-MM-DD" (default today, Belgian time)',
112
+ },
113
+ },
114
+ required: ['id'],
115
+ },
116
+ },
117
+ {
118
+ name: 'irail_disturbances',
119
+ description:
120
+ 'Current disturbances, incidents, and planned works on the Belgian rail network (SNCB NMBS Infrabel) — strikes, track works, line closures affecting Belgian train service. Returns title, summary, type (planned or disturbance), link, and last-updated time. Example: irail_disturbances({})',
121
+ inputSchema: {
122
+ type: 'object' as const,
123
+ properties: {
124
+ limit: { type: 'number', description: 'Max disturbances to return, 1-50 (default 15)' },
125
+ },
126
+ required: [],
127
+ },
128
+ },
129
+ ];
130
+
131
+ // ── helpers ─────────────────────────────────────────────────────────
132
+
133
+ /** Major-city aliases → main station. iRail's fuzzy match is greedy
134
+ * ("antwerp" → Antwerp-Haven, "gent" → Gentbrugge), so bare city names
135
+ * are pointed at the station travelers almost always mean. */
136
+ const STATION_ALIASES: Record<string, string> = {
137
+ antwerp: 'Antwerp-Central',
138
+ antwerpen: 'Antwerp-Central',
139
+ anvers: 'Antwerp-Central',
140
+ brussels: 'Brussels-Central',
141
+ brussel: 'Brussels-Central',
142
+ bruxelles: 'Brussels-Central',
143
+ 'brussels midi': 'Brussels-South',
144
+ 'brussels airport': 'Brussels Airport - Zaventem',
145
+ zaventem: 'Brussels Airport - Zaventem',
146
+ ghent: 'Ghent-Sint-Pieters',
147
+ gent: 'Ghent-Sint-Pieters',
148
+ gand: 'Ghent-Sint-Pieters',
149
+ brugge: 'Bruges',
150
+ liege: 'Liège-Guillemins',
151
+ liège: 'Liège-Guillemins',
152
+ luik: 'Liège-Guillemins',
153
+ charleroi: 'Charleroi-Central',
154
+ namen: 'Namur',
155
+ louvain: 'Leuven',
156
+ malines: 'Mechelen',
157
+ oostende: 'Ostend',
158
+ courtrai: 'Kortrijk',
159
+ };
160
+
161
+ function resolveStation(input: unknown, argName: string): string {
162
+ const raw = String(input ?? '').trim();
163
+ if (!raw) {
164
+ throw new Error(
165
+ `iRail: the \`${argName}\` argument is required — a Belgian station name, e.g. "Brussels-South", "Antwerp-Central", "Ghent-Sint-Pieters".`,
166
+ );
167
+ }
168
+ const key = raw.toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
169
+ return STATION_ALIASES[key] ?? raw;
170
+ }
171
+
172
+ const STATION_HINT =
173
+ 'Station unrecognized. Use the official station name — English exonyms work: "Brussels-South", "Brussels-Central", "Antwerp-Central", "Ghent-Sint-Pieters", "Liège-Guillemins", "Bruges", "Leuven". Partial names can match the wrong station (e.g. "antwerp" alone is Antwerp-Haven), so prefer the full name.';
174
+
175
+ /** Unix-epoch string/number → "YYYY-MM-DDTHH:MM" in Europe/Brussels. */
176
+ function toBrusselsIso(unix: string | number | undefined | null): string | null {
177
+ const n = Number(unix);
178
+ if (!n || !Number.isFinite(n)) return null;
179
+ const parts = new Intl.DateTimeFormat('en-CA', {
180
+ timeZone: 'Europe/Brussels',
181
+ year: 'numeric',
182
+ month: '2-digit',
183
+ day: '2-digit',
184
+ hour: '2-digit',
185
+ minute: '2-digit',
186
+ hourCycle: 'h23',
187
+ }).formatToParts(new Date(n * 1000));
188
+ const p: Record<string, string> = {};
189
+ for (const { type, value } of parts) p[type] = value;
190
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`;
191
+ }
192
+
193
+ /** Delay seconds (string) → whole minutes. */
194
+ function delayMinutes(seconds: string | number | undefined): number {
195
+ const n = Number(seconds);
196
+ return Number.isFinite(n) && n > 0 ? Math.round(n / 60) : 0;
197
+ }
198
+
199
+ function isTrue(flag: string | number | undefined): boolean {
200
+ return flag === '1' || flag === 1;
201
+ }
202
+
203
+ /** Parse "YYYY-MM-DDTHH:MM", "YYYY-MM-DD HH:MM", or "HH:MM" (Belgian local)
204
+ * into the API's { date: DDMMYY, time: HHMM }. */
205
+ function parseWhen(input: string, argName: string): { date?: string; time: string } {
206
+ const s = input.trim();
207
+ let m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})/);
208
+ if (m) {
209
+ return { date: `${m[3]}${m[2]}${m[1].slice(2)}`, time: `${m[4].padStart(2, '0')}${m[5]}` };
210
+ }
211
+ m = s.match(/^(\d{1,2}):(\d{2})$/);
212
+ if (m) {
213
+ return { time: `${m[1].padStart(2, '0')}${m[2]}` };
214
+ }
215
+ throw new Error(
216
+ `iRail: could not parse \`${argName}\` value "${input}". Use "YYYY-MM-DDTHH:MM" (e.g. "2026-07-20T09:00") or "HH:MM" for today, Belgian local time.`,
217
+ );
218
+ }
219
+
220
+ async function api(path: string, params: Record<string, string>): Promise<unknown> {
221
+ const qs = new URLSearchParams({ ...params, format: 'json', lang: 'en' });
222
+ const controller = new AbortController();
223
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
224
+ let res: Response;
225
+ try {
226
+ // iRail 303-redirects every request to a versioned path; Workers fetch
227
+ // follows redirects by default — keep it that way.
228
+ res = await fetch(`${BASE_URL}${path}?${qs}`, {
229
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
230
+ signal: controller.signal,
231
+ });
232
+ } catch (err) {
233
+ if (err instanceof Error && err.name === 'AbortError') {
234
+ throw new Error(
235
+ `iRail: request timed out after ${TIMEOUT_MS / 1000}s. The API may be briefly overloaded — retry once.`,
236
+ );
237
+ }
238
+ throw err;
239
+ } finally {
240
+ clearTimeout(timer);
241
+ }
242
+ if (!res.ok) {
243
+ let message = `HTTP ${res.status}`;
244
+ try {
245
+ const body = (await res.json()) as { exception?: string; message?: string };
246
+ if (body.message) message = body.message;
247
+ if (body.exception === 'RequestedStopNotFoundException') {
248
+ throw new Error(`iRail: ${message}. ${STATION_HINT}`);
249
+ }
250
+ } catch (err) {
251
+ if (err instanceof Error && err.message.startsWith('iRail:')) throw err;
252
+ }
253
+ throw new Error(`iRail ${path} error: ${message}`);
254
+ }
255
+ return res.json();
256
+ }
257
+
258
+ // ── response shapes (only the fields we read) ───────────────────────
259
+
260
+ interface StationInfo {
261
+ name?: string;
262
+ standardname?: string;
263
+ id?: string;
264
+ }
265
+
266
+ interface VehicleInfo {
267
+ shortname?: string;
268
+ name?: string;
269
+ type?: string;
270
+ number?: string;
271
+ locationX?: string;
272
+ locationY?: string;
273
+ }
274
+
275
+ interface PlatformInfo {
276
+ name?: string;
277
+ normal?: string;
278
+ }
279
+
280
+ interface BoardEntry {
281
+ station?: string;
282
+ stationinfo?: StationInfo;
283
+ time?: string;
284
+ delay?: string;
285
+ canceled?: string;
286
+ left?: string;
287
+ vehicle?: string;
288
+ vehicleinfo?: VehicleInfo;
289
+ platform?: string;
290
+ platforminfo?: PlatformInfo;
291
+ occupancy?: { name?: string };
292
+ }
293
+
294
+ interface ConnectionEnd {
295
+ station?: string;
296
+ stationinfo?: StationInfo;
297
+ time?: string;
298
+ delay?: string;
299
+ canceled?: string;
300
+ vehicle?: string;
301
+ vehicleinfo?: VehicleInfo;
302
+ platform?: string;
303
+ platforminfo?: PlatformInfo;
304
+ direction?: { name?: string };
305
+ walking?: string;
306
+ }
307
+
308
+ interface Via {
309
+ station?: string;
310
+ stationinfo?: StationInfo;
311
+ arrival?: ConnectionEnd;
312
+ departure?: ConnectionEnd;
313
+ timebetween?: string;
314
+ }
315
+
316
+ interface Connection {
317
+ departure?: ConnectionEnd;
318
+ arrival?: ConnectionEnd;
319
+ duration?: string;
320
+ vias?: { number?: string; via?: Via[] };
321
+ alerts?: { number?: string; alert?: Array<{ header?: string; description?: string }> };
322
+ remarks?: { number?: string; remark?: Array<{ description?: string }> };
323
+ }
324
+
325
+ interface VehicleStop {
326
+ station?: string;
327
+ stationinfo?: StationInfo;
328
+ scheduledArrivalTime?: string;
329
+ scheduledDepartureTime?: string;
330
+ arrivalDelay?: string;
331
+ departureDelay?: string;
332
+ delay?: string;
333
+ canceled?: string;
334
+ arrivalCanceled?: string;
335
+ departureCanceled?: string;
336
+ left?: string;
337
+ arrived?: string;
338
+ platform?: string;
339
+ platforminfo?: PlatformInfo;
340
+ occupancy?: { name?: string };
341
+ }
342
+
343
+ interface Disturbance {
344
+ title?: string;
345
+ description?: string;
346
+ type?: string;
347
+ link?: string;
348
+ timestamp?: string;
349
+ }
350
+
351
+ // ── shapers ─────────────────────────────────────────────────────────
352
+
353
+ function trainName(v?: VehicleInfo, fallback?: string): string | undefined {
354
+ return v?.shortname ?? fallback?.replace(/^BE\.NMBS\./, '');
355
+ }
356
+
357
+ function cleanPlatform(platform?: string): string | null {
358
+ return platform && platform !== '?' ? platform : null;
359
+ }
360
+
361
+ function shapeBoardEntry(e: BoardEntry, direction: 'destination' | 'origin') {
362
+ return {
363
+ train: trainName(e.vehicleinfo, e.vehicle),
364
+ train_type: e.vehicleinfo?.type,
365
+ [direction]: e.station ?? e.stationinfo?.name,
366
+ scheduled_time: toBrusselsIso(e.time),
367
+ delay_minutes: delayMinutes(e.delay),
368
+ platform: cleanPlatform(e.platform),
369
+ platform_changed: e.platforminfo ? !isTrue(e.platforminfo.normal) : undefined,
370
+ canceled: isTrue(e.canceled),
371
+ left: isTrue(e.left),
372
+ occupancy: e.occupancy?.name !== 'unknown' ? e.occupancy?.name : undefined,
373
+ };
374
+ }
375
+
376
+ function shapeLeg(end: ConnectionEnd | undefined, kind: 'departure' | 'arrival') {
377
+ if (!end) return null;
378
+ return {
379
+ station: end.station ?? end.stationinfo?.name,
380
+ scheduled_time: toBrusselsIso(end.time),
381
+ delay_minutes: delayMinutes(end.delay),
382
+ platform: cleanPlatform(end.platform),
383
+ platform_changed: end.platforminfo ? !isTrue(end.platforminfo.normal) : undefined,
384
+ canceled: isTrue(end.canceled),
385
+ train: trainName(end.vehicleinfo, end.vehicle),
386
+ train_type: end.vehicleinfo?.type,
387
+ direction: kind === 'departure' ? end.direction?.name : undefined,
388
+ };
389
+ }
390
+
391
+ function shapeConnection(c: Connection) {
392
+ const vias = c.vias?.via ?? [];
393
+ return {
394
+ departure: shapeLeg(c.departure, 'departure'),
395
+ arrival: shapeLeg(c.arrival, 'arrival'),
396
+ duration_minutes: Math.round(Number(c.duration ?? 0) / 60),
397
+ transfers: vias.length,
398
+ via: vias.map((v) => ({
399
+ station: v.station ?? v.stationinfo?.name,
400
+ arrive: {
401
+ train: trainName(v.arrival?.vehicleinfo, v.arrival?.vehicle),
402
+ scheduled_time: toBrusselsIso(v.arrival?.time),
403
+ delay_minutes: delayMinutes(v.arrival?.delay),
404
+ platform: cleanPlatform(v.arrival?.platform),
405
+ },
406
+ depart: {
407
+ train: trainName(v.departure?.vehicleinfo, v.departure?.vehicle),
408
+ scheduled_time: toBrusselsIso(v.departure?.time),
409
+ delay_minutes: delayMinutes(v.departure?.delay),
410
+ platform: cleanPlatform(v.departure?.platform),
411
+ },
412
+ transfer_minutes: v.timebetween ? Math.round(Number(v.timebetween) / 60) : undefined,
413
+ })),
414
+ alerts: (c.alerts?.alert ?? [])
415
+ .map((a) => (a.header ?? a.description ?? '').slice(0, 200))
416
+ .filter(Boolean),
417
+ };
418
+ }
419
+
420
+ // ── tool implementations ────────────────────────────────────────────
421
+
422
+ function clampLimit(value: unknown, def: number, max: number): number {
423
+ const n = Number(value);
424
+ return Number.isFinite(n) && n > 0 ? Math.min(Math.round(n), max) : def;
425
+ }
426
+
427
+ async function liveboard(args: Record<string, unknown>) {
428
+ const station = resolveStation(args.station, 'station');
429
+ const type = String(args.type ?? 'departure').toLowerCase().startsWith('arr') ? 'arrival' : 'departure';
430
+ const limit = clampLimit(args.limit, 15, 50);
431
+ const data = (await api('/liveboard/', { station, arrdep: type })) as {
432
+ station?: string;
433
+ stationinfo?: StationInfo;
434
+ departures?: { departure?: BoardEntry[] };
435
+ arrivals?: { arrival?: BoardEntry[] };
436
+ };
437
+ const entries = type === 'arrival' ? data.arrivals?.arrival ?? [] : data.departures?.departure ?? [];
438
+ return {
439
+ station: data.station ?? station,
440
+ station_local_name: data.stationinfo?.standardname,
441
+ board_type: type,
442
+ timezone: 'Europe/Brussels',
443
+ count: Math.min(entries.length, limit),
444
+ total_on_board: entries.length,
445
+ [type === 'arrival' ? 'arrivals' : 'departures']: entries
446
+ .slice(0, limit)
447
+ .map((e) => shapeBoardEntry(e, type === 'arrival' ? 'origin' : 'destination')),
448
+ };
449
+ }
450
+
451
+ async function journey(args: Record<string, unknown>) {
452
+ const from = resolveStation(args.from, 'from');
453
+ const to = resolveStation(args.to, 'to');
454
+ const limit = clampLimit(args.limit, 4, 6);
455
+ const params: Record<string, string> = { from, to };
456
+ const arriveBy = args.arrive_by != null && String(args.arrive_by).trim() !== '';
457
+ const whenRaw = arriveBy ? String(args.arrive_by) : args.depart_at != null ? String(args.depart_at) : '';
458
+ if (whenRaw.trim()) {
459
+ const when = parseWhen(whenRaw, arriveBy ? 'arrive_by' : 'depart_at');
460
+ params.time = when.time;
461
+ if (when.date) params.date = when.date;
462
+ params.timesel = arriveBy ? 'arrival' : 'departure';
463
+ }
464
+ const data = (await api('/connections/', params)) as { connection?: Connection[] };
465
+ const connections = data.connection ?? [];
466
+ return {
467
+ from,
468
+ to,
469
+ timesel: params.timesel ?? 'departure',
470
+ timezone: 'Europe/Brussels',
471
+ count: Math.min(connections.length, limit),
472
+ connections: connections.slice(0, limit).map(shapeConnection),
473
+ };
474
+ }
475
+
476
+ async function train(args: Record<string, unknown>) {
477
+ const raw = String(args.id ?? '').trim();
478
+ if (!raw) {
479
+ throw new Error(
480
+ 'iRail: the `id` argument is required — a train number like "IC1832" (from irail_liveboard or irail_journey).',
481
+ );
482
+ }
483
+ // Normalize "BE.NMBS.IC1832", "IC 1832", "ic1832" → "IC1832"
484
+ const id = raw.replace(/^BE\.NMBS\./i, '').replace(/\s+/g, '').toUpperCase();
485
+ const params: Record<string, string> = { id };
486
+ const dateRaw = String(args.date ?? '').trim();
487
+ if (dateRaw) {
488
+ const m = dateRaw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
489
+ if (!m) throw new Error(`iRail: could not parse \`date\` value "${dateRaw}". Use "YYYY-MM-DD".`);
490
+ params.date = `${m[3]}${m[2]}${m[1].slice(2)}`;
491
+ }
492
+ const data = (await api('/vehicle/', params)) as {
493
+ vehicle?: string;
494
+ vehicleinfo?: VehicleInfo;
495
+ stops?: { stop?: VehicleStop[] };
496
+ };
497
+ const stops = data.stops?.stop ?? [];
498
+ const passed = stops.filter((s) => isTrue(s.left) || isTrue(s.arrived));
499
+ const last = passed[passed.length - 1];
500
+ const currentDelay = last
501
+ ? delayMinutes(last.delay ?? last.departureDelay)
502
+ : delayMinutes(stops[0]?.delay ?? stops[0]?.departureDelay);
503
+ const lat = Number(data.vehicleinfo?.locationY);
504
+ const lon = Number(data.vehicleinfo?.locationX);
505
+ return {
506
+ train: trainName(data.vehicleinfo, data.vehicle),
507
+ train_type: data.vehicleinfo?.type,
508
+ timezone: 'Europe/Brussels',
509
+ current_delay_minutes: currentDelay,
510
+ position: lat && lon ? { latitude: lat, longitude: lon } : undefined,
511
+ stop_count: stops.length,
512
+ stops: stops.map((s) => ({
513
+ station: s.station ?? s.stationinfo?.name,
514
+ scheduled_arrival: toBrusselsIso(s.scheduledArrivalTime),
515
+ scheduled_departure: toBrusselsIso(s.scheduledDepartureTime),
516
+ arrival_delay_minutes: delayMinutes(s.arrivalDelay),
517
+ departure_delay_minutes: delayMinutes(s.departureDelay),
518
+ platform: cleanPlatform(s.platform),
519
+ canceled: isTrue(s.canceled) || (isTrue(s.arrivalCanceled) && isTrue(s.departureCanceled)),
520
+ passed: isTrue(s.left),
521
+ occupancy: s.occupancy?.name !== 'unknown' ? s.occupancy?.name : undefined,
522
+ })),
523
+ };
524
+ }
525
+
526
+ async function disturbances(args: Record<string, unknown>) {
527
+ const limit = clampLimit(args.limit, 15, 50);
528
+ const data = (await api('/disturbances/', {})) as { disturbance?: Disturbance[] };
529
+ const items = data.disturbance ?? [];
530
+ return {
531
+ count: Math.min(items.length, limit),
532
+ total: items.length,
533
+ timezone: 'Europe/Brussels',
534
+ disturbances: items.slice(0, limit).map((d) => ({
535
+ title: d.title,
536
+ description:
537
+ d.description && d.description.length > 300 ? `${d.description.slice(0, 300)}…` : d.description,
538
+ type: d.type,
539
+ link: d.link,
540
+ updated: toBrusselsIso(d.timestamp),
541
+ })),
542
+ };
543
+ }
544
+
545
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
546
+ switch (name) {
547
+ case 'irail_liveboard':
548
+ return liveboard(args);
549
+ case 'irail_journey':
550
+ return journey(args);
551
+ case 'irail_train':
552
+ return train(args);
553
+ case 'irail_disturbances':
554
+ return disturbances(args);
555
+ default:
556
+ throw new Error(`Unknown tool: ${name}`);
557
+ }
558
+ }
559
+
560
+ 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
+ }