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