@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 +8 -0
- package/24918_process.log +13 -0
- package/CLAUDE.md +51 -0
- package/README.md +118 -0
- package/build/api-client.js +3 -0
- package/build/index.js +108 -0
- package/build/instructions.js +38 -0
- package/build/slim.js +139 -0
- package/build/tools/find-journeys.js +38 -0
- package/build/tools/find-station.js +24 -0
- package/build/tools/find-trip.js +105 -0
- package/build/tools/get-departures.js +39 -0
- package/language.json +1 -0
- package/package.json +1 -0
- package/package_name +1 -0
- package/push_info.json +5 -0
- package/src/api-client.ts +4 -0
- package/src/db-vendo-client.d.ts +34 -0
- package/src/index.ts +135 -0
- package/src/instructions.ts +38 -0
- package/src/slim.ts +148 -0
- package/src/tools/find-journeys.ts +46 -0
- package/src/tools/find-station.ts +31 -0
- package/src/tools/find-trip.ts +140 -0
- package/src/tools/get-departures.ts +47 -0
- package/tsconfig.json +13 -0
package/.mcp.json
ADDED
|
@@ -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
|
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
|
+
}
|