mcp-server-sibra 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/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # 🚌 Serveur MCP Sibra — Annecy
2
+
3
+ Serveur [Model Context Protocol](https://modelcontextprotocol.io) pour interroger
4
+ les horaires de bus du réseau **Sibra** (Grand Annecy) depuis Claude.
5
+
6
+ ---
7
+
8
+ ## Fonctionnalités
9
+
10
+ | Outil | Description |
11
+ |---|---|
12
+ | `list_lines` | Liste toutes les lignes du réseau |
13
+ | `search_stops` | Recherche un arrêt par nom |
14
+ | `next_departures` | Prochains départs depuis un arrêt |
15
+ | `line_schedule` | Premier/dernier départ d'une ligne |
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ ### Prérequis
22
+
23
+ - Node.js ≥ 18
24
+ - npm
25
+
26
+ ### 1. Installer les dépendances et compiler
27
+
28
+ ```bash
29
+ npm install
30
+ npm run build
31
+ ```
32
+
33
+ ### 2. Télécharger les données GTFS Sibra
34
+
35
+ Les données proviennent du [Point d'Accès National aux données de mobilité](https://transport.data.gouv.fr/datasets/offre-de-transports-sibra-a-annecy-gtfs), sous licence **ODbL**.
36
+
37
+ Le serveur **télécharge automatiquement** le fichier GTFS au premier démarrage
38
+ et le met en cache dans `.cache/gtfs-sibra.zip` (renouvellement toutes les 24h).
39
+
40
+ Ou téléchargez manuellement :
41
+ ```bash
42
+ mkdir -p .cache
43
+ curl -L -o .cache/gtfs-sibra.zip \
44
+ "https://www.data.gouv.fr/api/1/datasets/r/8b12f6db-9aa7-43dc-a179-013998a1c4c0"
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Connexion à Claude Desktop
50
+
51
+ Ajoutez cette configuration dans `claude_desktop_config.json` :
52
+
53
+ **macOS** : `~/Library/Application Support/Claude/claude_desktop_config.json`
54
+ **Windows** : `%APPDATA%\Claude\claude_desktop_config.json`
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "sibra-annecy": {
60
+ "command": "node",
61
+ "args": ["/chemin/absolu/vers/sibra-mcp/build/index.js"]
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ Remplacez `/chemin/absolu/vers/sibra-mcp` par le chemin réel sur votre machine.
68
+
69
+ ---
70
+
71
+ ## Test rapide (MCP Inspector)
72
+
73
+ ```bash
74
+ npx @modelcontextprotocol/inspector node build/index.js
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Exemples de questions à poser à Claude
80
+
81
+ > "Quelles sont les lignes de bus Sibra ?"
82
+
83
+ > "À quelle heure part le prochain bus depuis la Gare d'Annecy ?"
84
+
85
+ > "Quels sont les horaires de la ligne 1 aujourd'hui ?"
86
+
87
+ > "Cherche l'arrêt 'Courier' sur le réseau Sibra"
88
+
89
+ ---
90
+
91
+ ## Variable d'environnement
92
+
93
+ | Variable | Description |
94
+ |---|---|
95
+ | `GTFS_FILE` | Chemin vers un fichier GTFS zip local (contourne le téléchargement) |
96
+
97
+ ```bash
98
+ GTFS_FILE=/path/to/gtfs.zip node build/index.js
99
+ ```
100
+
101
+ ---
102
+
103
+ ## Structure du projet
104
+
105
+ ```
106
+ sibra-mcp/
107
+ ├── src/
108
+ │ ├── index.ts # Serveur MCP + 4 outils
109
+ │ └── gtfs.ts # Chargeur et parseur GTFS
110
+ ├── build/ # Code compilé (généré par npm run build)
111
+ ├── .cache/ # Cache GTFS (généré automatiquement)
112
+ ├── tsconfig.json
113
+ └── package.json
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Données
119
+
120
+ - **Source** : [transport.data.gouv.fr — Sibra](https://transport.data.gouv.fr/datasets/offre-de-transports-sibra-a-annecy-gtfs)
121
+ - **Format** : GTFS (General Transit Feed Specification)
122
+ - **Licence** : [ODbL](https://opendatacommons.org/licenses/odbl/)
123
+ - **Producteur** : Sibra / CA du Grand Annecy
124
+
125
+ ## Étapes suivantes
126
+
127
+ - Ajouter le module **bornes de recharge IRVE**
128
+ - Ajouter le module **Vélonecy** (vélos en libre-service)
129
+ - Ajouter la disponibilité **temps réel** (GTFS-RT si Sibra le publie)
package/build/gtfs.js ADDED
@@ -0,0 +1,242 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as https from "https";
4
+ import { fileURLToPath } from "url";
5
+ import AdmZip from "adm-zip";
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+ const GTFS_URL = "https://www.data.gouv.fr/api/1/datasets/r/8b12f6db-9aa7-43dc-a179-013998a1c4c0";
9
+ const CACHE_DIR = path.join(__dirname, "..", ".cache");
10
+ const CACHE_FILE = path.join(CACHE_DIR, "gtfs-sibra.zip");
11
+ const CACHE_MAX_AGE_HOURS = 24;
12
+ // Optionally point to a local zip: GTFS_FILE=/path/to/gtfs.zip node build/index.js
13
+ const LOCAL_OVERRIDE = './gtfs-sibra.zip';
14
+ function parseCSV(content) {
15
+ const lines = content.replace(/\r/g, "").split("\n");
16
+ if (lines.length < 2)
17
+ return [];
18
+ const headers = lines[0]
19
+ .split(",")
20
+ .map((h) => h.trim().replace(/^"/, "").replace(/"$/, ""));
21
+ const result = [];
22
+ for (let i = 1; i < lines.length; i++) {
23
+ const line = lines[i].trim();
24
+ if (!line)
25
+ continue;
26
+ const values = [];
27
+ let cur = "";
28
+ let inQ = false;
29
+ for (const ch of line) {
30
+ if (ch === '"') {
31
+ inQ = !inQ;
32
+ }
33
+ else if (ch === "," && !inQ) {
34
+ values.push(cur);
35
+ cur = "";
36
+ }
37
+ else {
38
+ cur += ch;
39
+ }
40
+ }
41
+ values.push(cur);
42
+ const row = {};
43
+ headers.forEach((h, idx) => {
44
+ row[h] = (values[idx] ?? "").trim();
45
+ });
46
+ result.push(row);
47
+ }
48
+ return result;
49
+ }
50
+ function isCacheValid() {
51
+ if (!fs.existsSync(CACHE_FILE))
52
+ return false;
53
+ const stat = fs.statSync(CACHE_FILE);
54
+ const ageHours = (Date.now() - stat.mtimeMs) / 3_600_000;
55
+ return ageHours < CACHE_MAX_AGE_HOURS;
56
+ }
57
+ async function downloadGTFS() {
58
+ return new Promise((resolve, reject) => {
59
+ const doGet = (url) => {
60
+ https
61
+ .get(url, (res) => {
62
+ if (res.statusCode === 301 || res.statusCode === 302) {
63
+ return doGet(res.headers.location);
64
+ }
65
+ if (res.statusCode !== 200) {
66
+ reject(new Error(`HTTP ${res.statusCode} en téléchargeant le GTFS`));
67
+ return;
68
+ }
69
+ const chunks = [];
70
+ res.on("data", (c) => chunks.push(c));
71
+ res.on("end", () => resolve(Buffer.concat(chunks)));
72
+ res.on("error", reject);
73
+ })
74
+ .on("error", reject);
75
+ };
76
+ doGet(GTFS_URL);
77
+ });
78
+ }
79
+ async function downloadGTFSWithRetry(retries = 3) {
80
+ for (let attempt = 1; attempt <= retries; attempt++) {
81
+ try {
82
+ return await downloadGTFS();
83
+ }
84
+ catch (err) {
85
+ if (attempt === retries)
86
+ throw err;
87
+ process.stderr.write(`Tentative ${attempt} échouée, nouvel essai...\n`);
88
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
89
+ }
90
+ }
91
+ throw new Error("Unreachable");
92
+ }
93
+ async function getZipBuffer() {
94
+ // 1. Env var override (useful for testing)
95
+ if (LOCAL_OVERRIDE && fs.existsSync(LOCAL_OVERRIDE)) {
96
+ process.stderr.write(`Utilisation du fichier GTFS local : ${LOCAL_OVERRIDE}\n`);
97
+ return fs.readFileSync(LOCAL_OVERRIDE);
98
+ }
99
+ // 2. Cache on disk
100
+ if (!fs.existsSync(CACHE_DIR))
101
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
102
+ if (!isCacheValid()) {
103
+ process.stderr.write("Téléchargement du GTFS Sibra depuis data.gouv.fr...\n");
104
+ const buf = await downloadGTFSWithRetry();
105
+ fs.writeFileSync(CACHE_FILE, buf);
106
+ return buf;
107
+ }
108
+ process.stderr.write("Utilisation du cache GTFS local.\n");
109
+ return fs.readFileSync(CACHE_FILE);
110
+ }
111
+ export async function loadGtfs() {
112
+ const buf = await getZipBuffer();
113
+ const zip = new AdmZip(buf);
114
+ const readFile = (name) => {
115
+ const entry = zip.getEntry(name);
116
+ if (!entry)
117
+ return "";
118
+ return zip.readAsText(entry);
119
+ };
120
+ const stops = new Map();
121
+ for (const row of parseCSV(readFile("stops.txt"))) {
122
+ stops.set(row.stop_id, {
123
+ stop_id: row.stop_id,
124
+ stop_name: row.stop_name,
125
+ stop_lat: parseFloat(row.stop_lat),
126
+ stop_lon: parseFloat(row.stop_lon),
127
+ });
128
+ }
129
+ const routes = new Map();
130
+ for (const row of parseCSV(readFile("routes.txt"))) {
131
+ routes.set(row.route_id, {
132
+ route_id: row.route_id,
133
+ route_short_name: row.route_short_name,
134
+ route_long_name: row.route_long_name,
135
+ route_color: row.route_color,
136
+ });
137
+ }
138
+ const trips = new Map();
139
+ for (const row of parseCSV(readFile("trips.txt"))) {
140
+ trips.set(row.trip_id, {
141
+ trip_id: row.trip_id,
142
+ route_id: row.route_id,
143
+ service_id: row.service_id,
144
+ trip_headsign: row.trip_headsign,
145
+ direction_id: row.direction_id,
146
+ });
147
+ }
148
+ const stopTimes = parseCSV(readFile("stop_times.txt")).map((row) => ({
149
+ trip_id: row.trip_id,
150
+ arrival_time: row.arrival_time,
151
+ departure_time: row.departure_time,
152
+ stop_id: row.stop_id,
153
+ stop_sequence: parseInt(row.stop_sequence, 10),
154
+ }));
155
+ const calendarDates = parseCSV(readFile("calendar_dates.txt")).map((row) => ({
156
+ service_id: row.service_id,
157
+ date: row.date,
158
+ exception_type: row.exception_type,
159
+ }));
160
+ const calendars = new Map();
161
+ for (const row of parseCSV(readFile("calendar.txt"))) {
162
+ calendars.set(row.service_id, {
163
+ service_id: row.service_id,
164
+ monday: row.monday === "1",
165
+ tuesday: row.tuesday === "1",
166
+ wednesday: row.wednesday === "1",
167
+ thursday: row.thursday === "1",
168
+ friday: row.friday === "1",
169
+ saturday: row.saturday === "1",
170
+ sunday: row.sunday === "1",
171
+ start_date: row.start_date,
172
+ end_date: row.end_date,
173
+ });
174
+ }
175
+ const stopTimesByStop = new Map();
176
+ const stopTimesByTrip = new Map();
177
+ for (const st of stopTimes) {
178
+ if (!stopTimesByStop.has(st.stop_id))
179
+ stopTimesByStop.set(st.stop_id, []);
180
+ stopTimesByStop.get(st.stop_id).push(st);
181
+ if (!stopTimesByTrip.has(st.trip_id))
182
+ stopTimesByTrip.set(st.trip_id, []);
183
+ stopTimesByTrip.get(st.trip_id).push(st);
184
+ }
185
+ return {
186
+ stops,
187
+ routes,
188
+ trips,
189
+ stopTimes,
190
+ stopTimesByStop,
191
+ stopTimesByTrip,
192
+ calendarDates,
193
+ calendars,
194
+ loadedAt: new Date(),
195
+ };
196
+ }
197
+ /** Return today as YYYYMMDD */
198
+ export function todayStr() {
199
+ return new Date().toISOString().slice(0, 10).replace(/-/g, "");
200
+ }
201
+ /** Day-of-week key from YYYYMMDD */
202
+ export function dayOfWeek(dateStr) {
203
+ const d = new Date(`${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`);
204
+ const days = [
205
+ "sunday",
206
+ "monday",
207
+ "tuesday",
208
+ "wednesday",
209
+ "thursday",
210
+ "friday",
211
+ "saturday",
212
+ ];
213
+ return days[d.getDay()];
214
+ }
215
+ /** Get active service_ids for a given date */
216
+ export function activeServices(gtfs, dateStr) {
217
+ const active = new Set();
218
+ const dow = dayOfWeek(dateStr);
219
+ for (const [sid, cal] of gtfs.calendars) {
220
+ if (dateStr >= cal.start_date &&
221
+ dateStr <= cal.end_date &&
222
+ cal[dow]) {
223
+ active.add(sid);
224
+ }
225
+ }
226
+ for (const cd of gtfs.calendarDates) {
227
+ if (cd.date === dateStr) {
228
+ if (cd.exception_type === "1")
229
+ active.add(cd.service_id);
230
+ if (cd.exception_type === "2")
231
+ active.delete(cd.service_id);
232
+ }
233
+ }
234
+ return active;
235
+ }
236
+ /** Normalise time like "25:10:00" → "HH:MM" */
237
+ export function fmtTime(t) {
238
+ if (!t)
239
+ return "";
240
+ const [h, m] = t.split(":");
241
+ return `${h.padStart(2, "0")}:${m}`;
242
+ }
package/build/index.js ADDED
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { loadGtfs, activeServices, fmtTime, todayStr, } from "./gtfs.js";
6
+ let gtfs = null;
7
+ async function getGtfs() {
8
+ if (!gtfs) {
9
+ process.stderr.write("Chargement des données GTFS Sibra...\n");
10
+ gtfs = await loadGtfs();
11
+ process.stderr.write(`GTFS chargé : ${gtfs.stops.size} arrêts, ${gtfs.routes.size} lignes, ${gtfs.trips.size} courses\n`);
12
+ }
13
+ return gtfs;
14
+ }
15
+ // ─── Server ───────────────────────────────────────────────────────────────────
16
+ const server = new McpServer({
17
+ name: "sibra-annecy",
18
+ version: "1.0.0",
19
+ });
20
+ // ─── Tool 1 : Lister les lignes ───────────────────────────────────────────────
21
+ server.registerTool("list_lines", {
22
+ description: "Liste toutes les lignes de bus du réseau Sibra d'Annecy avec leur nom court, nom long et destination.",
23
+ inputSchema: z.object({}),
24
+ }, async () => {
25
+ const g = await getGtfs();
26
+ const lines = [...g.routes.values()]
27
+ .sort((a, b) => {
28
+ const na = parseInt(a.route_short_name) || 999;
29
+ const nb = parseInt(b.route_short_name) || 999;
30
+ return na !== nb ? na - nb : a.route_short_name.localeCompare(b.route_short_name);
31
+ })
32
+ .map((r) => `Ligne ${r.route_short_name} — ${r.route_long_name}`)
33
+ .join("\n");
34
+ return {
35
+ content: [{ type: "text", text: `Lignes du réseau Sibra :\n\n${lines}` }],
36
+ };
37
+ });
38
+ // ─── Tool 2 : Rechercher des arrêts ──────────────────────────────────────────
39
+ server.registerTool("search_stops", {
40
+ description: "Recherche des arrêts de bus Sibra par nom (recherche partielle, insensible à la casse).",
41
+ inputSchema: z.object({
42
+ query: z.string().describe("Nom ou partie du nom de l'arrêt recherché"),
43
+ }),
44
+ }, async ({ query }) => {
45
+ const g = await getGtfs();
46
+ const q = query.toLowerCase();
47
+ const results = [...g.stops.values()]
48
+ .filter((s) => s.stop_name.toLowerCase().includes(q))
49
+ .slice(0, 20)
50
+ .map((s) => `${s.stop_name} (id: ${s.stop_id})`)
51
+ .join("\n");
52
+ if (!results) {
53
+ return {
54
+ content: [{ type: "text", text: `Aucun arrêt trouvé pour "${query}".` }],
55
+ };
56
+ }
57
+ return {
58
+ content: [
59
+ {
60
+ type: "text",
61
+ text: `Arrêts correspondant à "${query}" :\n\n${results}`,
62
+ },
63
+ ],
64
+ };
65
+ });
66
+ // ─── Tool 3 : Prochains départs depuis un arrêt ───────────────────────────────
67
+ server.registerTool("next_departures", {
68
+ description: "Donne les prochains départs depuis un arrêt Sibra pour une heure et date données. Si aucune heure n'est précisée, utilise l'heure actuelle.",
69
+ inputSchema: z.object({
70
+ stop_name: z
71
+ .string()
72
+ .describe("Nom (ou partie du nom) de l'arrêt, ex: 'Gare', 'Courier'"),
73
+ time: z
74
+ .string()
75
+ .optional()
76
+ .describe("Heure de départ au format HH:MM (défaut: maintenant)"),
77
+ date: z
78
+ .string()
79
+ .optional()
80
+ .describe("Date au format YYYYMMDD (défaut: aujourd'hui)"),
81
+ limit: z
82
+ .number()
83
+ .optional()
84
+ .describe("Nombre max de résultats (défaut: 10)"),
85
+ }),
86
+ }, async ({ stop_name, time, date, limit = 10 }) => {
87
+ const g = await getGtfs();
88
+ const dateStr = date ?? todayStr();
89
+ const nowTime = time ??
90
+ new Date().toLocaleTimeString("fr-FR", {
91
+ hour: "2-digit",
92
+ minute: "2-digit",
93
+ hour12: false,
94
+ });
95
+ // Find matching stops
96
+ const q = stop_name.toLowerCase();
97
+ const matchingStops = [...g.stops.values()].filter((s) => s.stop_name.toLowerCase().includes(q));
98
+ if (matchingStops.length === 0) {
99
+ return {
100
+ content: [
101
+ {
102
+ type: "text",
103
+ text: `Arrêt "${stop_name}" introuvable. Utilisez search_stops pour trouver le nom exact.`,
104
+ },
105
+ ],
106
+ };
107
+ }
108
+ if (matchingStops.length > 5) {
109
+ const names = matchingStops
110
+ .slice(0, 5)
111
+ .map((s) => s.stop_name)
112
+ .join(", ");
113
+ return {
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text: `Plusieurs arrêts correspondent à "${stop_name}": ${names}… Précisez le nom.`,
118
+ },
119
+ ],
120
+ };
121
+ }
122
+ const activeServiceIds = activeServices(g, dateStr);
123
+ const departures = [];
124
+ for (const stop of matchingStops) {
125
+ for (const st of g.stopTimesByStop.get(stop.stop_id) ?? []) {
126
+ const trip = g.trips.get(st.trip_id);
127
+ if (!trip || !activeServiceIds.has(trip.service_id))
128
+ continue;
129
+ const deptTime = fmtTime(st.departure_time);
130
+ if (deptTime < nowTime)
131
+ continue;
132
+ const route = g.routes.get(trip.route_id);
133
+ departures.push({
134
+ time: deptTime,
135
+ line: route?.route_short_name ?? trip.route_id,
136
+ headsign: trip.trip_headsign ?? "",
137
+ });
138
+ }
139
+ }
140
+ departures.sort((a, b) => a.time.localeCompare(b.time));
141
+ const top = departures.slice(0, limit);
142
+ if (top.length === 0) {
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `Aucun départ trouvé depuis "${matchingStops[0].stop_name}" après ${nowTime} le ${dateStr}.`,
148
+ },
149
+ ],
150
+ };
151
+ }
152
+ const stopLabel = matchingStops.length === 1
153
+ ? matchingStops[0].stop_name
154
+ : matchingStops.map((s) => s.stop_name).join(" / ");
155
+ const rows = top
156
+ .map((d) => ` ${d.time} → Ligne ${d.line.padEnd(4)} ${d.headsign}`)
157
+ .join("\n");
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: `Prochains départs depuis **${stopLabel}** après ${nowTime} (${dateStr}) :\n\n${rows}`,
163
+ },
164
+ ],
165
+ };
166
+ });
167
+ // ─── Tool 4 : Horaires complets d'une ligne ───────────────────────────────────
168
+ server.registerTool("line_schedule", {
169
+ description: "Affiche les premiers et derniers départs d'une ligne Sibra pour un jour donné, avec les principaux arrêts.",
170
+ inputSchema: z.object({
171
+ line: z.string().describe("Numéro ou nom de la ligne, ex: '1', '2', 'T1'"),
172
+ date: z
173
+ .string()
174
+ .optional()
175
+ .describe("Date au format YYYYMMDD (défaut: aujourd'hui)"),
176
+ }),
177
+ }, async ({ line, date }) => {
178
+ const g = await getGtfs();
179
+ const dateStr = date ?? todayStr();
180
+ // Find route
181
+ const q = line.toLowerCase();
182
+ const matchingRoutes = [...g.routes.values()].filter((r) => r.route_short_name.toLowerCase() === q ||
183
+ r.route_long_name.toLowerCase().includes(q));
184
+ if (matchingRoutes.length === 0) {
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text",
189
+ text: `Ligne "${line}" introuvable. Utilisez list_lines pour voir les lignes disponibles.`,
190
+ },
191
+ ],
192
+ };
193
+ }
194
+ const route = matchingRoutes[0];
195
+ const activeServiceIds = activeServices(g, dateStr);
196
+ // Get trips for this route on this date
197
+ const routeTrips = [...g.trips.values()].filter((t) => t.route_id === route.route_id && activeServiceIds.has(t.service_id));
198
+ if (routeTrips.length === 0) {
199
+ return {
200
+ content: [
201
+ {
202
+ type: "text",
203
+ text: `Aucune course pour la ligne ${route.route_short_name} le ${dateStr}. Jour férié ou hors période de validité ?`,
204
+ },
205
+ ],
206
+ };
207
+ }
208
+ const summaries = [];
209
+ for (const trip of routeTrips) {
210
+ const times = (g.stopTimesByTrip.get(trip.trip_id) ?? [])
211
+ .slice()
212
+ .sort((a, b) => a.stop_sequence - b.stop_sequence);
213
+ if (times.length === 0)
214
+ continue;
215
+ summaries.push({
216
+ headsign: trip.trip_headsign ?? "",
217
+ firstTime: fmtTime(times[0].departure_time),
218
+ lastTime: fmtTime(times[times.length - 1].arrival_time),
219
+ direction: trip.direction_id === "1" ? "Retour" : "Aller",
220
+ });
221
+ }
222
+ summaries.sort((a, b) => a.firstTime.localeCompare(b.firstTime));
223
+ // Group by direction/headsign
224
+ const groups = new Map();
225
+ for (const s of summaries) {
226
+ const key = `${s.direction} → ${s.headsign}`;
227
+ if (!groups.has(key))
228
+ groups.set(key, []);
229
+ groups.get(key).push(s);
230
+ }
231
+ let text = `**Ligne ${route.route_short_name}** — ${route.route_long_name}\nDate : ${dateStr} | ${summaries.length} courses\n\n`;
232
+ for (const [label, trips] of groups) {
233
+ const first = trips[0].firstTime;
234
+ const last = trips[trips.length - 1].firstTime;
235
+ text += `${label}\n Premier départ : ${first} | Dernier départ : ${last} | ${trips.length} passages\n\n`;
236
+ }
237
+ return {
238
+ content: [{ type: "text", text }],
239
+ };
240
+ });
241
+ // ─── Tool 5 : Arrêts d'une course ────────────────────────────────────────────
242
+ server.registerTool("trip_stops", {
243
+ description: "Affiche tous les arrêts d'une course Sibra avec les heures de passage, à partir d'un arrêt de départ et d'une heure donnés.",
244
+ inputSchema: z.object({
245
+ stop_name: z.string().describe("Nom (ou partie du nom) de l'arrêt de départ"),
246
+ line: z.string().describe("Numéro de la ligne, ex: '1', 'T1'"),
247
+ time: z.string().optional().describe("Heure de départ au format HH:MM (défaut: maintenant)"),
248
+ date: z.string().optional().describe("Date au format YYYYMMDD (défaut: aujourd'hui)"),
249
+ }),
250
+ }, async ({ stop_name, line, time, date }) => {
251
+ const g = await getGtfs();
252
+ const dateStr = date ?? todayStr();
253
+ const nowTime = time ??
254
+ new Date().toLocaleTimeString("fr-FR", {
255
+ hour: "2-digit",
256
+ minute: "2-digit",
257
+ hour12: false,
258
+ });
259
+ const q = line.toLowerCase();
260
+ const route = [...g.routes.values()].find((r) => r.route_short_name.toLowerCase() === q || r.route_long_name.toLowerCase().includes(q));
261
+ if (!route) {
262
+ return { content: [{ type: "text", text: `Ligne "${line}" introuvable. Utilisez list_lines.` }] };
263
+ }
264
+ const sq = stop_name.toLowerCase();
265
+ const matchingStop = [...g.stops.values()].find((s) => s.stop_name.toLowerCase().includes(sq));
266
+ if (!matchingStop) {
267
+ return { content: [{ type: "text", text: `Arrêt "${stop_name}" introuvable. Utilisez search_stops.` }] };
268
+ }
269
+ const activeServiceIds = activeServices(g, dateStr);
270
+ // Find the next trip of this line stopping at this stop after nowTime
271
+ const candidates = (g.stopTimesByStop.get(matchingStop.stop_id) ?? [])
272
+ .filter((st) => {
273
+ const trip = g.trips.get(st.trip_id);
274
+ return (trip &&
275
+ trip.route_id === route.route_id &&
276
+ activeServiceIds.has(trip.service_id) &&
277
+ fmtTime(st.departure_time) >= nowTime);
278
+ })
279
+ .sort((a, b) => fmtTime(a.departure_time).localeCompare(fmtTime(b.departure_time)));
280
+ if (candidates.length === 0) {
281
+ return { content: [{ type: "text", text: `Aucune course ligne ${route.route_short_name} depuis "${matchingStop.stop_name}" après ${nowTime}.` }] };
282
+ }
283
+ const tripId = candidates[0].trip_id;
284
+ const trip = g.trips.get(tripId);
285
+ const allStops = (g.stopTimesByTrip.get(tripId) ?? [])
286
+ .slice()
287
+ .sort((a, b) => a.stop_sequence - b.stop_sequence);
288
+ const rows = allStops.map((st) => {
289
+ const s = g.stops.get(st.stop_id);
290
+ const marker = st.stop_id === matchingStop.stop_id ? " â—„" : "";
291
+ return ` ${fmtTime(st.departure_time)} ${s?.stop_name ?? st.stop_id}${marker}`;
292
+ }).join("\n");
293
+ return {
294
+ content: [{
295
+ type: "text",
296
+ text: `**Ligne ${route.route_short_name}** → ${trip.trip_headsign ?? ""}\nDate : ${dateStr}\n\n${rows}`,
297
+ }],
298
+ };
299
+ });
300
+ // ─── Start ────────────────────────────────────────────────────────────────────
301
+ async function main() {
302
+ const transport = new StdioServerTransport();
303
+ await server.connect(transport);
304
+ process.stderr.write("Serveur MCP Sibra démarré (stdio)\n");
305
+ }
306
+ main().catch((err) => {
307
+ process.stderr.write(`Erreur fatale : ${err}\n`);
308
+ process.exit(1);
309
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "mcp-server-sibra",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Sibra bus network schedules (Grand Annecy, France)",
5
+ "type": "module",
6
+ "main": "build/index.js",
7
+ "bin": {
8
+ "mcp-server-sibra": "build/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node build/index.js",
13
+ "dev": "tsc --watch"
14
+ },
15
+ "dependencies": {
16
+ "@modelcontextprotocol/sdk": "^1.12.0",
17
+ "adm-zip": "^0.5.16",
18
+ "zod": "^3.25.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/adm-zip": "^0.5.7",
22
+ "@types/node": "^22.0.0",
23
+ "typescript": "^5.8.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "license": "MIT"
29
+ }