@iflow-mcp/faaak2-db-mcp 1.0.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/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "db-mcp": {
4
+ "type": "http",
5
+ "url": "https://mcp-builder.de/db/mcp"
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,13 @@
1
+ [2026-03-17] 开始处理项目 mcp-db
2
+ [2026-03-17] 步骤1完成:成功fork并克隆项目
3
+ [2026-03-17] 步骤2完成:代码阅读和类型判断
4
+ - 项目类型:Node.js(TypeScript)
5
+ - 支持协议:stdio, Streamable HTTP
6
+ - 工具数量:4个
7
+ - 符合npm合规政策
8
+ [2026-03-17] 步骤3完成:本地测试
9
+ - 修改package.json:name改为@iflow-mcp/faaak2-db-mcp,添加bin字段
10
+ - 构建成功
11
+ - 本地测试通过,检测到4个工具:find_station, get_departures, find_trip, find_journeys
12
+ [2026-03-17] 步骤4完成:成功创建并推送iflow分支
13
+ [2026-03-17] 步骤5开始:上传到官方仓库
package/CLAUDE.md ADDED
@@ -0,0 +1,51 @@
1
+ ## Scope Rule
2
+ - Thorough inside the task boundary, hands-off outside it
3
+ - Within scope: find root causes, don't patch symptoms
4
+ - Outside scope: don't touch it, even if it's ugly
5
+
6
+ ## Plan Mode
7
+ - Use plan mode when there are multiple valid approaches or architectural decisions
8
+ - If something goes sideways, STOP and re-plan — don't keep pushing
9
+ - Skip plan mode when the path is obvious, even if many steps
10
+
11
+ ## Self-Improvement Loop
12
+ - After ANY correction, update auto-memory (MEMORY.md or topic files)
13
+ - Write rules that prevent the same mistake recurring
14
+ - Keep MEMORY.md as concise index, use topic files for details
15
+
16
+ ## Verification
17
+ - Never mark a task complete without proving it works
18
+ - Run tests, check logs, demonstrate correctness
19
+ - "Would a staff engineer approve this?"
20
+
21
+ ## Bug Fixing
22
+ - When given a bug: fix it autonomously
23
+ - Follow logs and errors to root cause, then resolve
24
+
25
+ ## Deployment
26
+
27
+ ### Production (mcp-builder.de)
28
+ - **URL:** `https://mcp-builder.de/db/mcp`
29
+ - **Health:** `GET /` → `{"status":"ok","name":"db-mcp"}`
30
+ - **Transport:** Streamable HTTP (MCP Spec 2025-03-26)
31
+ - **Hosting:** Systemd service behind Caddy reverse proxy
32
+
33
+ ### Transport-Logik (`src/index.ts`)
34
+ - `PORT` env var gesetzt → Streamable HTTP
35
+ - Kein `PORT` → stdio (lokale Nutzung via Claude Desktop/Claude Code)
36
+
37
+ ### MCP-Client Konfiguration (Remote)
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "db-mcp": {
42
+ "url": "https://mcp-builder.de/db/mcp"
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ ### Lokaler Test HTTP-Modus
49
+ ```bash
50
+ npm run serve # startet auf PORT=3000
51
+ ```
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # db-mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server providing real-time Deutsche Bahn travel data — departures, journeys, trip details, and station search.
4
+
5
+ ## Quick Start
6
+
7
+ ### Claude Code
8
+
9
+ ```bash
10
+ claude mcp add db-mcp --transport http https://mcp-builder.de/db/mcp
11
+ ```
12
+
13
+ ### Claude Desktop / Other MCP Clients
14
+
15
+ Add to your MCP client config:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "db-mcp": {
21
+ "url": "https://mcp-builder.de/db/mcp"
22
+ }
23
+ }
24
+ }
25
+ ```
26
+
27
+ ### Local (stdio)
28
+
29
+ ```bash
30
+ git clone <repo-url> && cd db
31
+ npm install && npm run build
32
+ ```
33
+
34
+ Then add to your client config:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "db-mcp": {
40
+ "command": "node",
41
+ "args": ["/absolute/path/to/db/build/index.js"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Tools
48
+
49
+ ### `find_station`
50
+
51
+ Search for a Deutsche Bahn station by name.
52
+
53
+ | Parameter | Type | Required | Description |
54
+ |-----------|--------|----------|-------------------------------|
55
+ | `query` | string | yes | Station name to search for |
56
+ | `results` | number | no | Number of results (default 1) |
57
+
58
+ ### `get_departures`
59
+
60
+ Get upcoming departures from a station.
61
+
62
+ | Parameter | Type | Required | Description |
63
+ |--------------|--------|----------|--------------------------------------------------|
64
+ | `station_id` | string | yes | Station ID (e.g. `8000261` for München Hbf) |
65
+ | `when` | string | no | ISO 8601 datetime (defaults to now) |
66
+ | `duration` | number | no | Duration in minutes to query (default 60) |
67
+
68
+ ### `find_trip`
69
+
70
+ Get full trip details for a specific train, including all stopovers and remarks.
71
+
72
+ | Parameter | Type | Required | Description |
73
+ |--------------|--------|----------|--------------------------------------------------|
74
+ | `train_name` | string | yes | Train name (e.g. `ICE 599`) |
75
+ | `station_id` | string | yes | Station ID (e.g. `8000261` for München Hbf) |
76
+ | `date` | string | yes | ISO date (e.g. `2026-03-08`) |
77
+
78
+ ### `find_journeys`
79
+
80
+ Find journey connections between two stations.
81
+
82
+ | Parameter | Type | Required | Description |
83
+ |-------------|--------|----------|--------------------------------------------------|
84
+ | `from_id` | string | yes | Departure station ID |
85
+ | `to_id` | string | yes | Arrival station ID |
86
+ | `departure` | string | yes | ISO datetime (e.g. `2026-03-08T14:00`) |
87
+ | `results` | number | no | Number of journeys to return (default 4) |
88
+
89
+ ## Server Instructions
90
+
91
+ The server includes built-in instructions that guide the LLM to:
92
+
93
+ - Always show actual (not planned) times, platforms, and line info
94
+ - Warn about platform changes
95
+ - Flag replacement bus services
96
+ - Inform about passenger rights when delays exceed 60 minutes
97
+ - Check reachable intermediate stops when suggesting alternatives
98
+
99
+ ## Transport Modes
100
+
101
+ The server auto-selects its transport based on the `PORT` environment variable:
102
+
103
+ | `PORT` set? | Transport | Use case |
104
+ |--------------|------------------|-----------------------|
105
+ | Yes | Streamable HTTP | Remote / hosted |
106
+ | No | stdio | Local via MCP client |
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ npm install
112
+ npm run build
113
+ npm run serve # starts HTTP on port 3000
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,3 @@
1
+ import { createClient } from "db-vendo-client";
2
+ import { profile as dbProfile } from "db-vendo-client/p/db/index.js";
3
+ export const client = createClient(dbProfile, "db-mcp-server");
package/build/index.js ADDED
@@ -0,0 +1,108 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { instructions } from "./instructions.js";
3
+ import { registerFindStation } from "./tools/find-station.js";
4
+ import { registerGetDepartures } from "./tools/get-departures.js";
5
+ import { registerFindTrip } from "./tools/find-trip.js";
6
+ import { registerFindJourneys } from "./tools/find-journeys.js";
7
+ function createServer() {
8
+ const server = new McpServer({ name: "db-mcp", version: "1.0.0" }, { instructions });
9
+ registerFindStation(server);
10
+ registerGetDepartures(server);
11
+ registerFindTrip(server);
12
+ registerFindJourneys(server);
13
+ return server;
14
+ }
15
+ if (process.env.PORT) {
16
+ // Remote: Streamable HTTP transport
17
+ const { createServer: createHttpServer } = await import("node:http");
18
+ const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
19
+ const PORT = parseInt(process.env.PORT, 10);
20
+ const sessions = new Map();
21
+ // Session timeout: clean up sessions after 30 min
22
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
23
+ setInterval(() => {
24
+ const now = Date.now();
25
+ for (const [id, session] of sessions) {
26
+ if (now - session.createdAt > SESSION_TIMEOUT_MS) {
27
+ session.transport.close?.();
28
+ sessions.delete(id);
29
+ console.log(`Session expired: ${id} (${sessions.size} active)`);
30
+ }
31
+ }
32
+ }, 60_000);
33
+ const httpServer = createHttpServer(async (req, res) => {
34
+ const url = new URL(req.url || "/", `http://localhost:${PORT}`);
35
+ // Health check for Railway
36
+ if (url.pathname === "/" && req.method === "GET") {
37
+ res.writeHead(200, { "Content-Type": "application/json" });
38
+ res.end(JSON.stringify({ status: "ok", name: "db-mcp" }));
39
+ return;
40
+ }
41
+ if (url.pathname !== "/mcp") {
42
+ res.writeHead(404, { "Content-Type": "application/json" });
43
+ res.end(JSON.stringify({ error: "Not found. Use POST /mcp" }));
44
+ return;
45
+ }
46
+ // CORS
47
+ res.setHeader("Access-Control-Allow-Origin", "https://mcp-builder.de");
48
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
49
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
50
+ res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
51
+ if (req.method === "OPTIONS") {
52
+ res.writeHead(204);
53
+ res.end();
54
+ return;
55
+ }
56
+ // Parse body for POST
57
+ let parsedBody = undefined;
58
+ if (req.method === "POST") {
59
+ const chunks = [];
60
+ for await (const chunk of req) {
61
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
62
+ }
63
+ parsedBody = JSON.parse(Buffer.concat(chunks).toString());
64
+ }
65
+ // Route by session
66
+ const sessionId = req.headers["mcp-session-id"];
67
+ if (sessionId && sessions.has(sessionId)) {
68
+ const session = sessions.get(sessionId);
69
+ await session.transport.handleRequest(req, res, parsedBody);
70
+ return;
71
+ }
72
+ if (sessionId && !sessions.has(sessionId)) {
73
+ // Session expired or lost (e.g. after redeploy) — tell client to re-initialize
74
+ res.writeHead(404, { "Content-Type": "application/json" });
75
+ res.end(JSON.stringify({ error: "Session not found. Please start a new session." }));
76
+ return;
77
+ }
78
+ // New session
79
+ const transport = new StreamableHTTPServerTransport({
80
+ sessionIdGenerator: () => crypto.randomUUID(),
81
+ onsessioninitialized: (id) => {
82
+ sessions.set(id, { server, transport, createdAt: Date.now() });
83
+ console.log(`Session created: ${id} (${sessions.size} active)`);
84
+ },
85
+ });
86
+ transport.onclose = () => {
87
+ const id = transport.sessionId;
88
+ if (id) {
89
+ sessions.delete(id);
90
+ console.log(`Session closed: ${id} (${sessions.size} active)`);
91
+ }
92
+ };
93
+ const server = createServer();
94
+ await server.connect(transport);
95
+ await transport.handleRequest(req, res, parsedBody);
96
+ });
97
+ httpServer.listen(PORT, "127.0.0.1", () => {
98
+ console.log(`DB-MCP Streamable HTTP server listening on port ${PORT}`);
99
+ console.log(`Endpoint: http://localhost:${PORT}/mcp`);
100
+ });
101
+ }
102
+ else {
103
+ // Local: stdio transport
104
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
105
+ const server = createServer();
106
+ const transport = new StdioServerTransport();
107
+ await server.connect(transport);
108
+ }
@@ -0,0 +1,38 @@
1
+ export const instructions = `Du bist ein Reiseassistent für Bahnreisen in Deutschland.
2
+ Du hast Zugriff auf Echtzeit-Daten der Deutschen Bahn über einen MCP Server.
3
+
4
+ ZEITBERECHNUNG
5
+ - Verwende für alle Berechnungen ("Liegt dieser Halt noch vor mir?") ausschließlich
6
+ die tatsächlichen Zeiten (departure/arrival), niemals die geplanten.
7
+ - Wenn departure/arrival = null: Schätzung aus plannedDeparture + departureDelay verwenden
8
+ und als Schätzung kennzeichnen.
9
+
10
+ PFLICHTANGABEN BEI JEDER VERBINDUNGSAUSKUNFT
11
+ - Abfahrtszeit (tatsächlich)
12
+ - Gleis (aktuell) — immer angeben, nie weglassen
13
+ - Linie und Richtung
14
+ - Umstiegsbahnhöfe
15
+ - Ankunftszeit am Ziel
16
+ - Aktive Störungshinweise (remarks vom Typ warning/status)
17
+
18
+ GLEISWECHSEL
19
+ - Wenn departurePlatform != plannedDeparturePlatform: explizit warnen.
20
+ - Beispiel: "⚠️ Gleiswechsel! Abfahrt jetzt Gleis 4 (geplant: Gleis 7)"
21
+
22
+ SCHIENENERSATZVERKEHR
23
+ - Wenn remarks einen Eintrag mit summary "replacement service" enthält:
24
+ Nutzer darauf hinweisen dass es sich um einen Ersatzbus handelt und
25
+ Bauarbeiten-Zeitraum aus dem status-Remark nennen.
26
+
27
+ FAHRGASTRECHTE — 60-MINUTEN-REGEL
28
+ - Wenn arrivalDelay oder departureDelay >= 3600 Sekunden (60 Minuten):
29
+ Folgenden Hinweis ausgeben:
30
+ "⚖️ Fahrgastrechte: Bei dieser Verspätung hast du Anspruch auf 25% Erstattung
31
+ des Ticketpreises. Ab 120 Minuten sind es 50%. Erstattung über bahn.de/fahrgastrechte
32
+ oder direkt am Serviceschalter."
33
+
34
+ ALTERNATIVE VERBINDUNGEN
35
+ - Immer alle noch erreichbaren Zwischenhalte des aktuellen Zuges prüfen,
36
+ nicht nur den Endbahnhof.
37
+ - departure-Parameter für find_journeys = tatsächliche Ankunft am jeweiligen
38
+ Halt (mit Verspätung), nicht die geplante Zeit.`;
package/build/slim.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Strips DB API responses down to the fields the LLM actually needs.
3
+ * Removes coordinates, operator details, ril100 IDs, excessive product info, etc.
4
+ * Also removes undefined/null values to minimize JSON size.
5
+ */
6
+ /** JSON.stringify replacer that drops undefined/null values */
7
+ function dropNulls(_key, value) {
8
+ return value === null || value === undefined ? undefined : value;
9
+ }
10
+ /** Compact JSON without pretty-printing, dropping nulls */
11
+ export function compact(obj) {
12
+ return JSON.stringify(obj, dropNulls);
13
+ }
14
+ function pick(obj, keys) {
15
+ if (!obj)
16
+ return {};
17
+ const out = {};
18
+ for (const k of keys) {
19
+ if (obj[k] !== undefined && obj[k] !== null)
20
+ out[k] = obj[k];
21
+ }
22
+ return out;
23
+ }
24
+ function slimStop(stop) {
25
+ if (!stop)
26
+ return null;
27
+ return { id: stop.id, name: stop.name };
28
+ }
29
+ function slimRemarks(remarks) {
30
+ if (!Array.isArray(remarks))
31
+ return undefined;
32
+ const kept = remarks
33
+ .filter((r) => r.type === "warning" || r.type === "status")
34
+ .map((r) => pick(r, ["type", "summary", "text", "validFrom", "validUntil"]));
35
+ return kept.length > 0 ? kept : undefined;
36
+ }
37
+ function slimStopover(s) {
38
+ const out = { stop: s.stop?.name ?? null };
39
+ // Only include times that exist
40
+ const arr = s.arrival ?? s.plannedArrival;
41
+ if (arr)
42
+ out.arrival = arr;
43
+ if (s.arrivalDelay)
44
+ out.arrivalDelay = s.arrivalDelay;
45
+ const dep = s.departure ?? s.plannedDeparture;
46
+ if (dep)
47
+ out.departure = dep;
48
+ if (s.departureDelay)
49
+ out.departureDelay = s.departureDelay;
50
+ // Platform only if present
51
+ const plat = s.departurePlatform ?? s.plannedDeparturePlatform;
52
+ if (plat)
53
+ out.platform = plat;
54
+ if (s.cancelled)
55
+ out.cancelled = true;
56
+ return out;
57
+ }
58
+ // ── public API ───────────────────────────────────────────────────────
59
+ function slimLeg(leg) {
60
+ const out = {
61
+ origin: slimStop(leg.origin),
62
+ destination: slimStop(leg.destination),
63
+ departure: leg.departure ?? leg.plannedDeparture,
64
+ plannedDeparture: leg.plannedDeparture,
65
+ departureDelay: leg.departureDelay ?? undefined,
66
+ arrival: leg.arrival ?? leg.plannedArrival,
67
+ plannedArrival: leg.plannedArrival,
68
+ arrivalDelay: leg.arrivalDelay ?? undefined,
69
+ };
70
+ if (leg.walking) {
71
+ out.walking = true;
72
+ if (leg.distance)
73
+ out.distance = leg.distance;
74
+ }
75
+ else {
76
+ out.line = leg.line ? pick(leg.line, ["name", "productName", "mode"]) : undefined;
77
+ out.direction = leg.direction;
78
+ out.departurePlatform = leg.departurePlatform ?? leg.plannedDeparturePlatform;
79
+ out.plannedDeparturePlatform = leg.plannedDeparturePlatform;
80
+ out.arrivalPlatform = leg.arrivalPlatform ?? leg.plannedArrivalPlatform;
81
+ out.plannedArrivalPlatform = leg.plannedArrivalPlatform;
82
+ if (leg.cancelled)
83
+ out.cancelled = true;
84
+ const remarks = slimRemarks(leg.remarks);
85
+ if (remarks)
86
+ out.remarks = remarks;
87
+ if (Array.isArray(leg.stopovers)) {
88
+ out.stopovers = leg.stopovers.map(slimStopover);
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+ export function slimJourneys(data) {
94
+ const journeys = data.journeys ?? [];
95
+ return {
96
+ journeys: journeys.map((j) => ({
97
+ legs: (j.legs ?? []).map(slimLeg),
98
+ ...(j.price ? { price: j.price } : {}),
99
+ })),
100
+ };
101
+ }
102
+ export function slimTrip(data) {
103
+ const trip = data.trip ?? data;
104
+ return {
105
+ trip: {
106
+ ...pick(trip, ["id", "direction", "cancelled"]),
107
+ line: trip.line ? pick(trip.line, ["name", "productName", "mode"]) : undefined,
108
+ origin: slimStop(trip.origin),
109
+ destination: slimStop(trip.destination),
110
+ departure: trip.departure ?? trip.plannedDeparture,
111
+ plannedDeparture: trip.plannedDeparture,
112
+ departureDelay: trip.departureDelay ?? undefined,
113
+ arrival: trip.arrival ?? trip.plannedArrival,
114
+ plannedArrival: trip.plannedArrival,
115
+ arrivalDelay: trip.arrivalDelay ?? undefined,
116
+ remarks: slimRemarks(trip.remarks),
117
+ stopovers: Array.isArray(trip.stopovers)
118
+ ? trip.stopovers.map(slimStopover)
119
+ : undefined,
120
+ },
121
+ };
122
+ }
123
+ export function slimDeparture(d) {
124
+ return {
125
+ tripId: d.tripId,
126
+ line: d.line ? pick(d.line, ["name", "productName", "mode"]) : undefined,
127
+ direction: d.direction,
128
+ when: d.when ?? d.plannedWhen,
129
+ plannedWhen: d.plannedWhen,
130
+ delay: d.delay ?? undefined,
131
+ platform: d.platform ?? d.plannedPlatform,
132
+ plannedPlatform: d.plannedPlatform,
133
+ ...(d.cancelled ? { cancelled: true } : {}),
134
+ remarks: slimRemarks(d.remarks),
135
+ };
136
+ }
137
+ export function slimDepartures(departures) {
138
+ return departures.map((d) => slimDeparture(d));
139
+ }
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { client } from "../api-client.js";
3
+ import { slimJourneys, compact } from "../slim.js";
4
+ export function registerFindJourneys(server) {
5
+ server.tool("find_journeys", "Find journey connections between two stations. Returns journeys with legs, lines, platforms, stopovers, and remarks.", {
6
+ from_id: z.string().describe("Departure station ID (e.g. '8000261' for München Hbf)"),
7
+ to_id: z.string().describe("Arrival station ID (e.g. '8000105' for Frankfurt Hbf)"),
8
+ departure: z.string().describe("ISO departure date/time (e.g. '2026-03-08T14:00')"),
9
+ results: z.number().default(4).describe("Number of journeys to return (default 4)"),
10
+ }, async ({ from_id, to_id, departure, results }) => {
11
+ try {
12
+ for (const [label, id] of [["from_id", from_id], ["to_id", to_id]]) {
13
+ if (!/^\d{6,9}$/.test(id)) {
14
+ return {
15
+ isError: true,
16
+ content: [{ type: "text", text: `find_journeys failed: Invalid ${label} '${id}'. Expected a numeric HAFAS ID (e.g. '8000261' for München Hbf). Use find_station to look up the correct ID.` }],
17
+ };
18
+ }
19
+ }
20
+ const data = await client.journeys(from_id, to_id, {
21
+ departure: new Date(departure),
22
+ results,
23
+ stopovers: true,
24
+ remarks: true,
25
+ });
26
+ const slim = slimJourneys(data);
27
+ return {
28
+ content: [{ type: "text", text: compact(slim) }],
29
+ };
30
+ }
31
+ catch (error) {
32
+ return {
33
+ isError: true,
34
+ content: [{ type: "text", text: `find_journeys failed: ${error instanceof Error ? error.message : String(error)}` }],
35
+ };
36
+ }
37
+ });
38
+ }
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ import { client } from "../api-client.js";
3
+ export function registerFindStation(server) {
4
+ server.tool("find_station", "Search for a Deutsche Bahn station by name. Returns raw JSON array of matching locations.", {
5
+ query: z.string().describe("Station name to search for"),
6
+ results: z
7
+ .number()
8
+ .default(1)
9
+ .describe("Number of results to return (default 1)"),
10
+ }, async ({ query, results }) => {
11
+ try {
12
+ const data = await client.locations(query, { results });
13
+ return {
14
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
15
+ };
16
+ }
17
+ catch (error) {
18
+ return {
19
+ isError: true,
20
+ content: [{ type: "text", text: `find_station failed: ${error instanceof Error ? error.message : String(error)}` }],
21
+ };
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+ import { client } from "../api-client.js";
3
+ import { slimTrip, compact } from "../slim.js";
4
+ const PRODUCT_PREFIXES = {
5
+ nationalExpress: ["ICE", "TGV", "RJ", "RJX", "ECE"],
6
+ national: ["IC", "EC", "EN", "NJ", "FLX"],
7
+ regionalExpress: ["RE", "IRE", "MEX", "FEX"],
8
+ regional: ["RB", "RS"],
9
+ suburban: ["S"],
10
+ };
11
+ function getProductFilters(trainName) {
12
+ const prefix = trainName.replace(/\s+/g, "").replace(/\d+$/, "").toUpperCase();
13
+ for (const [product, prefixes] of Object.entries(PRODUCT_PREFIXES)) {
14
+ if (prefixes.includes(prefix)) {
15
+ const products = {
16
+ nationalExpress: false,
17
+ national: false,
18
+ regionalExpress: false,
19
+ regional: false,
20
+ suburban: false,
21
+ bus: false,
22
+ ferry: false,
23
+ subway: false,
24
+ tram: false,
25
+ };
26
+ products[product] = true;
27
+ return products;
28
+ }
29
+ }
30
+ // Unknown prefix — don't filter
31
+ return undefined;
32
+ }
33
+ export function registerFindTrip(server) {
34
+ server.tool("find_trip", "Find a specific train trip by name and station. Fetches all departures for the day, matches the train, then retrieves full trip details with stopovers and remarks.", {
35
+ train_name: z.string().describe("Train name to search for (e.g. 'ICE 599')"),
36
+ station_id: z.string().describe("Station ID (e.g. '8000261' for München Hbf)"),
37
+ date: z.string().describe("ISO date string (e.g. '2026-03-08')"),
38
+ }, async ({ train_name, station_id, date }) => {
39
+ try {
40
+ if (!/^\d{6,9}$/.test(station_id)) {
41
+ return {
42
+ isError: true,
43
+ content: [{ type: "text", text: `find_trip failed: Invalid station_id '${station_id}'. Expected a numeric HAFAS ID (e.g. '8000261' for München Hbf). Use find_station to look up the correct ID.` }],
44
+ };
45
+ }
46
+ // Step 1: Get departures for the day (db profile max 720 min, so split into two 12h windows)
47
+ const products = getProductFilters(train_name);
48
+ const baseOpt = { duration: 720 };
49
+ if (products)
50
+ baseOpt.products = products;
51
+ const res1 = await client.departures(station_id, {
52
+ ...baseOpt,
53
+ when: new Date(`${date}T00:00`),
54
+ });
55
+ let departures = (res1.departures ?? []);
56
+ // Search second half of day if needed
57
+ const normalize = (s) => s.toLowerCase().replace(/\s+/g, "");
58
+ let match = departures.find((d) => d.line?.name != null &&
59
+ normalize(d.line.name) === normalize(train_name));
60
+ if (!match) {
61
+ const res2 = await client.departures(station_id, {
62
+ ...baseOpt,
63
+ when: new Date(`${date}T12:00`),
64
+ });
65
+ const moreDepartures = (res2.departures ?? []);
66
+ departures = [...departures, ...moreDepartures];
67
+ match = moreDepartures.find((d) => d.line?.name != null &&
68
+ normalize(d.line.name) === normalize(train_name));
69
+ }
70
+ if (!match || !match.tripId) {
71
+ const availableNames = [
72
+ ...new Set(departures
73
+ .map((d) => d.line?.name)
74
+ .filter((n) => !!n)),
75
+ ];
76
+ return {
77
+ content: [
78
+ {
79
+ type: "text",
80
+ text: JSON.stringify({
81
+ error: `No departure found matching '${train_name}' at station ${station_id} on ${date}`,
82
+ available_trains: availableNames,
83
+ }, null, 2),
84
+ },
85
+ ],
86
+ };
87
+ }
88
+ // Step 2: Fetch full trip details
89
+ const trip = await client.trip(match.tripId, match.line?.name ?? "", {
90
+ stopovers: true,
91
+ remarks: true,
92
+ });
93
+ const slim = slimTrip(trip);
94
+ return {
95
+ content: [{ type: "text", text: compact(slim) }],
96
+ };
97
+ }
98
+ catch (error) {
99
+ return {
100
+ isError: true,
101
+ content: [{ type: "text", text: `find_trip failed: ${error instanceof Error ? error.message : String(error)}` }],
102
+ };
103
+ }
104
+ });
105
+ }