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 +129 -0
- package/build/gtfs.js +242 -0
- package/build/index.js +309 -0
- package/package.json +29 -0
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
|
+
}
|