@iflow-mcp/faaak2-db-mcp 1.0.2 → 1.0.3
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/{src/api-client.ts → build/api-client.js} +0 -1
- package/build/index.js +108 -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/package.json +1 -1
- package/.mcp.json +0 -8
- package/24918_process.log +0 -14
- package/CLAUDE.md +0 -51
- package/language.json +0 -1
- package/package_name +0 -1
- package/push_info.json +0 -5
- package/server_config.json +0 -1
- package/src/db-vendo-client.d.ts +0 -34
- package/src/index.ts +0 -135
- package/src/slim.ts +0 -148
- package/src/tools/find-journeys.ts +0 -46
- package/src/tools/find-station.ts +0 -31
- package/src/tools/find-trip.ts +0 -140
- package/src/tools/get-departures.ts +0 -47
- package/tsconfig.json +0 -13
- /package/{src/instructions.ts → build/instructions.js} +0 -0
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
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { client } from "../api-client.js";
|
|
3
|
+
import { slimDepartures, compact } from "../slim.js";
|
|
4
|
+
export function registerGetDepartures(server) {
|
|
5
|
+
server.tool("get_departures", "Get upcoming departures from a Deutsche Bahn station. Returns raw JSON array of departures with line.name, direction, when, delay, platform, remarks.", {
|
|
6
|
+
station_id: z.string().describe("Station ID (e.g. '8000261' for München Hbf)"),
|
|
7
|
+
when: z
|
|
8
|
+
.string()
|
|
9
|
+
.optional()
|
|
10
|
+
.describe("ISO 8601 datetime string (optional, defaults to now)"),
|
|
11
|
+
duration: z
|
|
12
|
+
.number()
|
|
13
|
+
.default(60)
|
|
14
|
+
.describe("Duration in minutes to query (default 60)"),
|
|
15
|
+
}, async ({ station_id, when, duration }) => {
|
|
16
|
+
try {
|
|
17
|
+
if (!/^\d{6,9}$/.test(station_id)) {
|
|
18
|
+
return {
|
|
19
|
+
isError: true,
|
|
20
|
+
content: [{ type: "text", text: `get_departures 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.` }],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const opt = { duration };
|
|
24
|
+
if (when)
|
|
25
|
+
opt.when = new Date(when);
|
|
26
|
+
const res = await client.departures(station_id, opt);
|
|
27
|
+
const slim = slimDepartures((res.departures ?? []));
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: compact(slim) }],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return {
|
|
34
|
+
isError: true,
|
|
35
|
+
content: [{ type: "text", text: `get_departures failed: ${error instanceof Error ? error.message : String(error)}` }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name": "@iflow-mcp/faaak2-db-mcp", "version": "1.0.
|
|
1
|
+
{"name": "@iflow-mcp/faaak2-db-mcp", "version": "1.0.3", "type": "module", "bin": {"iflow-mcp_faaak2-db-mcp": "cli.js"}, "files": ["build", "cli.js", "package.json"], "scripts": {"build": "tsc", "start": "node build/index.js", "serve": "PORT=3000 node build/index.js"}, "dependencies": {"@modelcontextprotocol/sdk": "^1.11.0", "db-vendo-client": "^6.10.8", "zod": "^3.24.0"}, "devDependencies": {"@types/node": "^22.0.0", "typescript": "^5.7.0"}}
|
package/.mcp.json
DELETED
package/24918_process.log
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
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完成:成功上传到npm官方仓库
|
|
14
|
-
[2026-03-17] 步骤6开始:生成ServerConfig并测试
|
package/CLAUDE.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
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/language.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
nodejs
|
package/package_name
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
@iflow-mcp/faaak2-db-mcp
|
package/push_info.json
DELETED
package/server_config.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"serverConfig": "{\"mcpServers\":{\"db-mcp\":{\"command\":\"npx\",\"args\":[\"-y\",\"-p\",\"node\",\"node\",\"-e\",\"import process from 'process';const {exec}=await import('child_process');const p=exec('npx -y @iflow-mcp/faaak2-db-mcp@latest');p.stdout.pipe(process.stdout);p.stderr.pipe(process.stderr);p.on('close',c=>process.exit(c))\"],\"values\":{}}}}"}
|
package/src/db-vendo-client.d.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
declare module "db-vendo-client" {
|
|
2
|
-
interface ClientOptions {
|
|
3
|
-
when?: Date;
|
|
4
|
-
duration?: number;
|
|
5
|
-
products?: Record<string, boolean>;
|
|
6
|
-
results?: number;
|
|
7
|
-
stopovers?: boolean;
|
|
8
|
-
remarks?: boolean;
|
|
9
|
-
departure?: Date;
|
|
10
|
-
arrival?: Date;
|
|
11
|
-
transfers?: number;
|
|
12
|
-
transferTime?: number;
|
|
13
|
-
language?: string;
|
|
14
|
-
polyline?: boolean;
|
|
15
|
-
[key: string]: unknown;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface Client {
|
|
19
|
-
locations(query: string, opt?: ClientOptions): Promise<unknown[]>;
|
|
20
|
-
departures(station: string, opt?: ClientOptions): Promise<{ departures: unknown[]; realtimeDataUpdatedAt?: number }>;
|
|
21
|
-
arrivals(station: string, opt?: ClientOptions): Promise<{ arrivals: unknown[]; realtimeDataUpdatedAt?: number }>;
|
|
22
|
-
journeys(from: string, to: string, opt?: ClientOptions): Promise<{ journeys: unknown[]; earlierRef?: string; laterRef?: string; realtimeDataUpdatedAt?: number }>;
|
|
23
|
-
trip(id: string, lineName: string, opt?: ClientOptions): Promise<unknown>;
|
|
24
|
-
stop(id: string, opt?: ClientOptions): Promise<unknown>;
|
|
25
|
-
nearby(location: { latitude: number; longitude: number }, opt?: ClientOptions): Promise<unknown[]>;
|
|
26
|
-
refreshJourney(refreshToken: string, opt?: ClientOptions): Promise<unknown>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function createClient(profile: unknown, userAgent: string): Client;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
declare module "db-vendo-client/p/db/index.js" {
|
|
33
|
-
export const profile: unknown;
|
|
34
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
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
|
-
|
|
8
|
-
function createServer(): McpServer {
|
|
9
|
-
const server = new McpServer(
|
|
10
|
-
{ name: "db-mcp", version: "1.0.0" },
|
|
11
|
-
{ instructions },
|
|
12
|
-
);
|
|
13
|
-
registerFindStation(server);
|
|
14
|
-
registerGetDepartures(server);
|
|
15
|
-
registerFindTrip(server);
|
|
16
|
-
registerFindJourneys(server);
|
|
17
|
-
return server;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
if (process.env.PORT) {
|
|
21
|
-
// Remote: Streamable HTTP transport
|
|
22
|
-
const { createServer: createHttpServer } = await import("node:http");
|
|
23
|
-
const { StreamableHTTPServerTransport } = await import(
|
|
24
|
-
"@modelcontextprotocol/sdk/server/streamableHttp.js"
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
const PORT = parseInt(process.env.PORT, 10);
|
|
28
|
-
const sessions = new Map<
|
|
29
|
-
string,
|
|
30
|
-
{ server: McpServer; transport: InstanceType<typeof StreamableHTTPServerTransport>; createdAt: number }
|
|
31
|
-
>();
|
|
32
|
-
|
|
33
|
-
// Session timeout: clean up sessions after 30 min
|
|
34
|
-
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
35
|
-
|
|
36
|
-
setInterval(() => {
|
|
37
|
-
const now = Date.now();
|
|
38
|
-
for (const [id, session] of sessions) {
|
|
39
|
-
if (now - session.createdAt > SESSION_TIMEOUT_MS) {
|
|
40
|
-
session.transport.close?.();
|
|
41
|
-
sessions.delete(id);
|
|
42
|
-
console.log(`Session expired: ${id} (${sessions.size} active)`);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}, 60_000);
|
|
46
|
-
|
|
47
|
-
const httpServer = createHttpServer(async (req, res) => {
|
|
48
|
-
const url = new URL(req.url || "/", `http://localhost:${PORT}`);
|
|
49
|
-
|
|
50
|
-
// Health check for Railway
|
|
51
|
-
if (url.pathname === "/" && req.method === "GET") {
|
|
52
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
53
|
-
res.end(JSON.stringify({ status: "ok", name: "db-mcp" }));
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (url.pathname !== "/mcp") {
|
|
58
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
59
|
-
res.end(JSON.stringify({ error: "Not found. Use POST /mcp" }));
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// CORS
|
|
64
|
-
res.setHeader("Access-Control-Allow-Origin", "https://mcp-builder.de");
|
|
65
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
66
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
|
|
67
|
-
res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
|
|
68
|
-
|
|
69
|
-
if (req.method === "OPTIONS") {
|
|
70
|
-
res.writeHead(204);
|
|
71
|
-
res.end();
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Parse body for POST
|
|
76
|
-
let parsedBody: unknown = undefined;
|
|
77
|
-
if (req.method === "POST") {
|
|
78
|
-
const chunks: Buffer[] = [];
|
|
79
|
-
for await (const chunk of req) {
|
|
80
|
-
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
81
|
-
}
|
|
82
|
-
parsedBody = JSON.parse(Buffer.concat(chunks).toString());
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Route by session
|
|
86
|
-
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
87
|
-
|
|
88
|
-
if (sessionId && sessions.has(sessionId)) {
|
|
89
|
-
const session = sessions.get(sessionId)!;
|
|
90
|
-
await session.transport.handleRequest(req, res, parsedBody);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (sessionId && !sessions.has(sessionId)) {
|
|
95
|
-
// Session expired or lost (e.g. after redeploy) — tell client to re-initialize
|
|
96
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
97
|
-
res.end(JSON.stringify({ error: "Session not found. Please start a new session." }));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// New session
|
|
102
|
-
const transport = new StreamableHTTPServerTransport({
|
|
103
|
-
sessionIdGenerator: () => crypto.randomUUID(),
|
|
104
|
-
onsessioninitialized: (id) => {
|
|
105
|
-
sessions.set(id, { server, transport, createdAt: Date.now() });
|
|
106
|
-
console.log(`Session created: ${id} (${sessions.size} active)`);
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
transport.onclose = () => {
|
|
111
|
-
const id = transport.sessionId;
|
|
112
|
-
if (id) {
|
|
113
|
-
sessions.delete(id);
|
|
114
|
-
console.log(`Session closed: ${id} (${sessions.size} active)`);
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const server = createServer();
|
|
119
|
-
await server.connect(transport);
|
|
120
|
-
await transport.handleRequest(req, res, parsedBody);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
httpServer.listen(PORT, "127.0.0.1", () => {
|
|
124
|
-
console.log(`DB-MCP Streamable HTTP server listening on port ${PORT}`);
|
|
125
|
-
console.log(`Endpoint: http://localhost:${PORT}/mcp`);
|
|
126
|
-
});
|
|
127
|
-
} else {
|
|
128
|
-
// Local: stdio transport
|
|
129
|
-
const { StdioServerTransport } = await import(
|
|
130
|
-
"@modelcontextprotocol/sdk/server/stdio.js"
|
|
131
|
-
);
|
|
132
|
-
const server = createServer();
|
|
133
|
-
const transport = new StdioServerTransport();
|
|
134
|
-
await server.connect(transport);
|
|
135
|
-
}
|
package/src/slim.ts
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
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
|
-
|
|
7
|
-
// ── helpers ──────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
interface AnyObj {
|
|
10
|
-
[key: string]: unknown;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** JSON.stringify replacer that drops undefined/null values */
|
|
14
|
-
function dropNulls(_key: string, value: unknown): unknown {
|
|
15
|
-
return value === null || value === undefined ? undefined : value;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Compact JSON without pretty-printing, dropping nulls */
|
|
19
|
-
export function compact(obj: unknown): string {
|
|
20
|
-
return JSON.stringify(obj, dropNulls);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function pick<T extends AnyObj>(obj: T | undefined | null, keys: string[]): AnyObj {
|
|
24
|
-
if (!obj) return {};
|
|
25
|
-
const out: AnyObj = {};
|
|
26
|
-
for (const k of keys) {
|
|
27
|
-
if (obj[k] !== undefined && obj[k] !== null) out[k] = obj[k];
|
|
28
|
-
}
|
|
29
|
-
return out;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function slimStop(stop: AnyObj | undefined | null): AnyObj | null {
|
|
33
|
-
if (!stop) return null;
|
|
34
|
-
return { id: stop.id, name: stop.name };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function slimRemarks(remarks: unknown): AnyObj[] | undefined {
|
|
38
|
-
if (!Array.isArray(remarks)) return undefined;
|
|
39
|
-
const kept = remarks
|
|
40
|
-
.filter((r: AnyObj) => r.type === "warning" || r.type === "status")
|
|
41
|
-
.map((r: AnyObj) => pick(r, ["type", "summary", "text", "validFrom", "validUntil"]));
|
|
42
|
-
return kept.length > 0 ? kept : undefined;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function slimStopover(s: AnyObj): AnyObj {
|
|
46
|
-
const out: AnyObj = { stop: (s.stop as AnyObj)?.name ?? null };
|
|
47
|
-
// Only include times that exist
|
|
48
|
-
const arr = s.arrival ?? s.plannedArrival;
|
|
49
|
-
if (arr) out.arrival = arr;
|
|
50
|
-
if (s.arrivalDelay) out.arrivalDelay = s.arrivalDelay;
|
|
51
|
-
const dep = s.departure ?? s.plannedDeparture;
|
|
52
|
-
if (dep) out.departure = dep;
|
|
53
|
-
if (s.departureDelay) out.departureDelay = s.departureDelay;
|
|
54
|
-
// Platform only if present
|
|
55
|
-
const plat = s.departurePlatform ?? s.plannedDeparturePlatform;
|
|
56
|
-
if (plat) out.platform = plat;
|
|
57
|
-
if (s.cancelled) out.cancelled = true;
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ── public API ───────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
function slimLeg(leg: AnyObj): AnyObj {
|
|
64
|
-
const out: AnyObj = {
|
|
65
|
-
origin: slimStop(leg.origin as AnyObj),
|
|
66
|
-
destination: slimStop(leg.destination as AnyObj),
|
|
67
|
-
departure: leg.departure ?? leg.plannedDeparture,
|
|
68
|
-
plannedDeparture: leg.plannedDeparture,
|
|
69
|
-
departureDelay: leg.departureDelay ?? undefined,
|
|
70
|
-
arrival: leg.arrival ?? leg.plannedArrival,
|
|
71
|
-
plannedArrival: leg.plannedArrival,
|
|
72
|
-
arrivalDelay: leg.arrivalDelay ?? undefined,
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
if (leg.walking) {
|
|
76
|
-
out.walking = true;
|
|
77
|
-
if (leg.distance) out.distance = leg.distance;
|
|
78
|
-
} else {
|
|
79
|
-
out.line = leg.line ? pick(leg.line as AnyObj, ["name", "productName", "mode"]) : undefined;
|
|
80
|
-
out.direction = leg.direction;
|
|
81
|
-
out.departurePlatform = leg.departurePlatform ?? leg.plannedDeparturePlatform;
|
|
82
|
-
out.plannedDeparturePlatform = leg.plannedDeparturePlatform;
|
|
83
|
-
out.arrivalPlatform = leg.arrivalPlatform ?? leg.plannedArrivalPlatform;
|
|
84
|
-
out.plannedArrivalPlatform = leg.plannedArrivalPlatform;
|
|
85
|
-
|
|
86
|
-
if (leg.cancelled) out.cancelled = true;
|
|
87
|
-
|
|
88
|
-
const remarks = slimRemarks(leg.remarks);
|
|
89
|
-
if (remarks) out.remarks = remarks;
|
|
90
|
-
|
|
91
|
-
if (Array.isArray(leg.stopovers)) {
|
|
92
|
-
out.stopovers = (leg.stopovers as AnyObj[]).map(slimStopover);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return out;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function slimJourneys(data: AnyObj): AnyObj {
|
|
100
|
-
const journeys = (data.journeys as AnyObj[]) ?? [];
|
|
101
|
-
return {
|
|
102
|
-
journeys: journeys.map((j) => ({
|
|
103
|
-
legs: ((j.legs as AnyObj[]) ?? []).map(slimLeg),
|
|
104
|
-
...(j.price ? { price: j.price } : {}),
|
|
105
|
-
})),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function slimTrip(data: AnyObj): AnyObj {
|
|
110
|
-
const trip = (data.trip as AnyObj) ?? data;
|
|
111
|
-
return {
|
|
112
|
-
trip: {
|
|
113
|
-
...pick(trip, ["id", "direction", "cancelled"]),
|
|
114
|
-
line: trip.line ? pick(trip.line as AnyObj, ["name", "productName", "mode"]) : undefined,
|
|
115
|
-
origin: slimStop(trip.origin as AnyObj),
|
|
116
|
-
destination: slimStop(trip.destination as AnyObj),
|
|
117
|
-
departure: trip.departure ?? trip.plannedDeparture,
|
|
118
|
-
plannedDeparture: trip.plannedDeparture,
|
|
119
|
-
departureDelay: trip.departureDelay ?? undefined,
|
|
120
|
-
arrival: trip.arrival ?? trip.plannedArrival,
|
|
121
|
-
plannedArrival: trip.plannedArrival,
|
|
122
|
-
arrivalDelay: trip.arrivalDelay ?? undefined,
|
|
123
|
-
remarks: slimRemarks(trip.remarks),
|
|
124
|
-
stopovers: Array.isArray(trip.stopovers)
|
|
125
|
-
? (trip.stopovers as AnyObj[]).map(slimStopover)
|
|
126
|
-
: undefined,
|
|
127
|
-
},
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function slimDeparture(d: AnyObj): AnyObj {
|
|
132
|
-
return {
|
|
133
|
-
tripId: d.tripId,
|
|
134
|
-
line: d.line ? pick(d.line as AnyObj, ["name", "productName", "mode"]) : undefined,
|
|
135
|
-
direction: d.direction,
|
|
136
|
-
when: d.when ?? d.plannedWhen,
|
|
137
|
-
plannedWhen: d.plannedWhen,
|
|
138
|
-
delay: d.delay ?? undefined,
|
|
139
|
-
platform: d.platform ?? d.plannedPlatform,
|
|
140
|
-
plannedPlatform: d.plannedPlatform,
|
|
141
|
-
...(d.cancelled ? { cancelled: true } : {}),
|
|
142
|
-
remarks: slimRemarks(d.remarks),
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function slimDepartures(departures: unknown[]): AnyObj[] {
|
|
147
|
-
return departures.map((d) => slimDeparture(d as AnyObj));
|
|
148
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { client } from "../api-client.js";
|
|
4
|
-
import { slimJourneys, compact } from "../slim.js";
|
|
5
|
-
|
|
6
|
-
export function registerFindJourneys(server: McpServer) {
|
|
7
|
-
server.tool(
|
|
8
|
-
"find_journeys",
|
|
9
|
-
"Find journey connections between two stations. Returns journeys with legs, lines, platforms, stopovers, and remarks.",
|
|
10
|
-
{
|
|
11
|
-
from_id: z.string().describe("Departure station ID (e.g. '8000261' for München Hbf)"),
|
|
12
|
-
to_id: z.string().describe("Arrival station ID (e.g. '8000105' for Frankfurt Hbf)"),
|
|
13
|
-
departure: z.string().describe("ISO departure date/time (e.g. '2026-03-08T14:00')"),
|
|
14
|
-
results: z.number().default(4).describe("Number of journeys to return (default 4)"),
|
|
15
|
-
},
|
|
16
|
-
async ({ from_id, to_id, departure, results }) => {
|
|
17
|
-
try {
|
|
18
|
-
for (const [label, id] of [["from_id", from_id], ["to_id", to_id]] as const) {
|
|
19
|
-
if (!/^\d{6,9}$/.test(id)) {
|
|
20
|
-
return {
|
|
21
|
-
isError: true,
|
|
22
|
-
content: [{ type: "text" as const, 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.` }],
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const data = await client.journeys(from_id, to_id, {
|
|
28
|
-
departure: new Date(departure),
|
|
29
|
-
results,
|
|
30
|
-
stopovers: true,
|
|
31
|
-
remarks: true,
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const slim = slimJourneys(data as Record<string, unknown>);
|
|
35
|
-
return {
|
|
36
|
-
content: [{ type: "text" as const, text: compact(slim) }],
|
|
37
|
-
};
|
|
38
|
-
} catch (error) {
|
|
39
|
-
return {
|
|
40
|
-
isError: true,
|
|
41
|
-
content: [{ type: "text" as const, text: `find_journeys failed: ${error instanceof Error ? error.message : String(error)}` }],
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
);
|
|
46
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { client } from "../api-client.js";
|
|
4
|
-
|
|
5
|
-
export function registerFindStation(server: McpServer) {
|
|
6
|
-
server.tool(
|
|
7
|
-
"find_station",
|
|
8
|
-
"Search for a Deutsche Bahn station by name. Returns raw JSON array of matching locations.",
|
|
9
|
-
{
|
|
10
|
-
query: z.string().describe("Station name to search for"),
|
|
11
|
-
results: z
|
|
12
|
-
.number()
|
|
13
|
-
.default(1)
|
|
14
|
-
.describe("Number of results to return (default 1)"),
|
|
15
|
-
},
|
|
16
|
-
async ({ query, results }) => {
|
|
17
|
-
try {
|
|
18
|
-
const data = await client.locations(query, { results });
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
22
|
-
};
|
|
23
|
-
} catch (error) {
|
|
24
|
-
return {
|
|
25
|
-
isError: true,
|
|
26
|
-
content: [{ type: "text" as const, text: `find_station failed: ${error instanceof Error ? error.message : String(error)}` }],
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
);
|
|
31
|
-
}
|
package/src/tools/find-trip.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { client } from "../api-client.js";
|
|
4
|
-
import { slimTrip, compact } from "../slim.js";
|
|
5
|
-
|
|
6
|
-
interface Departure {
|
|
7
|
-
tripId?: string;
|
|
8
|
-
line?: { name?: string };
|
|
9
|
-
[key: string]: unknown;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const PRODUCT_PREFIXES: Record<string, string[]> = {
|
|
13
|
-
nationalExpress: ["ICE", "TGV", "RJ", "RJX", "ECE"],
|
|
14
|
-
national: ["IC", "EC", "EN", "NJ", "FLX"],
|
|
15
|
-
regionalExpress: ["RE", "IRE", "MEX", "FEX"],
|
|
16
|
-
regional: ["RB", "RS"],
|
|
17
|
-
suburban: ["S"],
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function getProductFilters(trainName: string): Record<string, boolean> | undefined {
|
|
21
|
-
const prefix = trainName.replace(/\s+/g, "").replace(/\d+$/, "").toUpperCase();
|
|
22
|
-
|
|
23
|
-
for (const [product, prefixes] of Object.entries(PRODUCT_PREFIXES)) {
|
|
24
|
-
if (prefixes.includes(prefix)) {
|
|
25
|
-
const products: Record<string, boolean> = {
|
|
26
|
-
nationalExpress: false,
|
|
27
|
-
national: false,
|
|
28
|
-
regionalExpress: false,
|
|
29
|
-
regional: false,
|
|
30
|
-
suburban: false,
|
|
31
|
-
bus: false,
|
|
32
|
-
ferry: false,
|
|
33
|
-
subway: false,
|
|
34
|
-
tram: false,
|
|
35
|
-
};
|
|
36
|
-
products[product] = true;
|
|
37
|
-
return products;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Unknown prefix — don't filter
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function registerFindTrip(server: McpServer) {
|
|
46
|
-
server.tool(
|
|
47
|
-
"find_trip",
|
|
48
|
-
"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.",
|
|
49
|
-
{
|
|
50
|
-
train_name: z.string().describe("Train name to search for (e.g. 'ICE 599')"),
|
|
51
|
-
station_id: z.string().describe("Station ID (e.g. '8000261' for München Hbf)"),
|
|
52
|
-
date: z.string().describe("ISO date string (e.g. '2026-03-08')"),
|
|
53
|
-
},
|
|
54
|
-
async ({ train_name, station_id, date }) => {
|
|
55
|
-
try {
|
|
56
|
-
if (!/^\d{6,9}$/.test(station_id)) {
|
|
57
|
-
return {
|
|
58
|
-
isError: true,
|
|
59
|
-
content: [{ type: "text" as const, 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.` }],
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Step 1: Get departures for the day (db profile max 720 min, so split into two 12h windows)
|
|
64
|
-
const products = getProductFilters(train_name);
|
|
65
|
-
const baseOpt: Record<string, unknown> = { duration: 720 };
|
|
66
|
-
if (products) baseOpt.products = products;
|
|
67
|
-
|
|
68
|
-
const res1 = await client.departures(station_id, {
|
|
69
|
-
...baseOpt,
|
|
70
|
-
when: new Date(`${date}T00:00`),
|
|
71
|
-
});
|
|
72
|
-
let departures: Departure[] = (res1.departures ?? []) as Departure[];
|
|
73
|
-
|
|
74
|
-
// Search second half of day if needed
|
|
75
|
-
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, "");
|
|
76
|
-
let match = departures.find(
|
|
77
|
-
(d) =>
|
|
78
|
-
d.line?.name != null &&
|
|
79
|
-
normalize(d.line.name) === normalize(train_name),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
if (!match) {
|
|
83
|
-
const res2 = await client.departures(station_id, {
|
|
84
|
-
...baseOpt,
|
|
85
|
-
when: new Date(`${date}T12:00`),
|
|
86
|
-
});
|
|
87
|
-
const moreDepartures = (res2.departures ?? []) as Departure[];
|
|
88
|
-
departures = [...departures, ...moreDepartures];
|
|
89
|
-
|
|
90
|
-
match = moreDepartures.find(
|
|
91
|
-
(d) =>
|
|
92
|
-
d.line?.name != null &&
|
|
93
|
-
normalize(d.line.name) === normalize(train_name),
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (!match || !match.tripId) {
|
|
98
|
-
const availableNames = [
|
|
99
|
-
...new Set(
|
|
100
|
-
departures
|
|
101
|
-
.map((d) => d.line?.name)
|
|
102
|
-
.filter((n): n is string => !!n),
|
|
103
|
-
),
|
|
104
|
-
];
|
|
105
|
-
return {
|
|
106
|
-
content: [
|
|
107
|
-
{
|
|
108
|
-
type: "text" as const,
|
|
109
|
-
text: JSON.stringify(
|
|
110
|
-
{
|
|
111
|
-
error: `No departure found matching '${train_name}' at station ${station_id} on ${date}`,
|
|
112
|
-
available_trains: availableNames,
|
|
113
|
-
},
|
|
114
|
-
null,
|
|
115
|
-
2,
|
|
116
|
-
),
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Step 2: Fetch full trip details
|
|
123
|
-
const trip = await client.trip(match.tripId, match.line?.name ?? "", {
|
|
124
|
-
stopovers: true,
|
|
125
|
-
remarks: true,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
const slim = slimTrip(trip as Record<string, unknown>);
|
|
129
|
-
return {
|
|
130
|
-
content: [{ type: "text" as const, text: compact(slim) }],
|
|
131
|
-
};
|
|
132
|
-
} catch (error) {
|
|
133
|
-
return {
|
|
134
|
-
isError: true,
|
|
135
|
-
content: [{ type: "text" as const, text: `find_trip failed: ${error instanceof Error ? error.message : String(error)}` }],
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
);
|
|
140
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { client } from "../api-client.js";
|
|
4
|
-
import { slimDepartures, compact } from "../slim.js";
|
|
5
|
-
|
|
6
|
-
export function registerGetDepartures(server: McpServer) {
|
|
7
|
-
server.tool(
|
|
8
|
-
"get_departures",
|
|
9
|
-
"Get upcoming departures from a Deutsche Bahn station. Returns raw JSON array of departures with line.name, direction, when, delay, platform, remarks.",
|
|
10
|
-
{
|
|
11
|
-
station_id: z.string().describe("Station ID (e.g. '8000261' for München Hbf)"),
|
|
12
|
-
when: z
|
|
13
|
-
.string()
|
|
14
|
-
.optional()
|
|
15
|
-
.describe("ISO 8601 datetime string (optional, defaults to now)"),
|
|
16
|
-
duration: z
|
|
17
|
-
.number()
|
|
18
|
-
.default(60)
|
|
19
|
-
.describe("Duration in minutes to query (default 60)"),
|
|
20
|
-
},
|
|
21
|
-
async ({ station_id, when, duration }) => {
|
|
22
|
-
try {
|
|
23
|
-
if (!/^\d{6,9}$/.test(station_id)) {
|
|
24
|
-
return {
|
|
25
|
-
isError: true,
|
|
26
|
-
content: [{ type: "text" as const, text: `get_departures 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.` }],
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const opt: Record<string, unknown> = { duration };
|
|
31
|
-
if (when) opt.when = new Date(when);
|
|
32
|
-
|
|
33
|
-
const res = await client.departures(station_id, opt);
|
|
34
|
-
|
|
35
|
-
const slim = slimDepartures((res.departures ?? []) as Record<string, unknown>[]);
|
|
36
|
-
return {
|
|
37
|
-
content: [{ type: "text" as const, text: compact(slim) }],
|
|
38
|
-
};
|
|
39
|
-
} catch (error) {
|
|
40
|
-
return {
|
|
41
|
-
isError: true,
|
|
42
|
-
content: [{ type: "text" as const, text: `get_departures failed: ${error instanceof Error ? error.message : String(error)}` }],
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
);
|
|
47
|
-
}
|
package/tsconfig.json
DELETED
|
File without changes
|