@opendata.cat/mcp-server 0.0.12 → 0.0.14

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 CHANGED
@@ -193,6 +193,24 @@ Usuari → LLM → MCP opendata.cat → API opendata.cat (cataleg)
193
193
 
194
194
  No emmagatzema ni fa de proxy de dades. Cada consulta va directament a la font oficial.
195
195
 
196
+ ## API REST
197
+
198
+ A mes del servidor MCP, opendata.cat ofereix una API REST publica per accedir al cataleg de datasets:
199
+
200
+ | Endpoint | Descripcio |
201
+ |----------|-----------|
202
+ | `GET /api/datasets.php?q=...` | Cerca datasets per text lliure |
203
+ | `GET /api/dataset.php?id=...` | Detall complet d'un dataset |
204
+ | `GET /api/categories.php` | Categories i portals amb comptadors |
205
+ | `GET /api/portals.php` | Portals de transparencia (1.769) |
206
+ | `GET /api/stats.php` | Estadistiques agregades |
207
+ | `GET /api/mcp-stats.php` | Metriques d'us del MCP |
208
+ | `POST /api/mcp` | Servidor MCP (Streamable HTTP) |
209
+
210
+ Documentacio interactiva (Swagger): **[opendata.cat/api/docs.html](https://opendata.cat/api/docs.html)**
211
+
212
+ Especificacio OpenAPI: [`opendata.cat/api/openapi.json`](https://opendata.cat/api/openapi.json)
213
+
196
214
  ## Sobre opendata.cat
197
215
 
198
216
  [opendata.cat](https://opendata.cat) es una associacio catalana sense anim de lucre fundada el 2012 (registre 47468) dedicada a promoure la transparencia i l'acces a la informacio publica. Treballa en tres eixos: **estandarditzacio** de formats i protocols, **formacio** especialitzada per a professionals i administracions, i **collaboracio** publico-privada per a l'obertura de dades.
@@ -206,6 +224,13 @@ Les contribucions son benvingudes! Per afegir un nou portal de dades obertes:
206
224
 
207
225
  ## Changelog
208
226
 
227
+ ### v0.0.13 (2026-04-14)
228
+ - Decodificador GTFS-RT integrat: trens FGC en temps real ara retornen dades reals (retards, alertes, posicions GPS)
229
+ - Descodifica automaticament fitxers Protocol Buffers (.pb) de GTFS Realtime
230
+ - API REST documentada amb Swagger UI a /api/docs.html (OpenAPI 3.1)
231
+ - Instruccions per a models locals: LM Studio, Ollama (MCPHost) i Jan
232
+ - Recomanacions de models oberts en catala (Softcatala): Qwen 3.5 9B, Gemma 3 12B
233
+
209
234
  ### v0.0.12 (2026-04-13)
210
235
  - 14 prompts predefinits (8 analisi + 6 descobriment)
211
236
  - Nous prompts: novetats, datasets_populars, explorar_portal, dades_municipi, datasets_temps_real, resum_portals
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Descarrega i decodifica un fitxer GTFS-RT (.pb) des d'Opendatasoft.
3
+ * Retorna dades estructurades: trip updates, alertes o posicions de vehicles.
4
+ */
5
+ export declare function decodeGtfsRt(endpoint: string, limit: number): Promise<{
6
+ type: string;
7
+ count: number;
8
+ data: Record<string, unknown>[];
9
+ }>;
@@ -0,0 +1,92 @@
1
+ import GtfsRealtimeBindings from "gtfs-realtime-bindings";
2
+ /**
3
+ * Descarrega i decodifica un fitxer GTFS-RT (.pb) des d'Opendatasoft.
4
+ * Retorna dades estructurades: trip updates, alertes o posicions de vehicles.
5
+ */
6
+ export async function decodeGtfsRt(endpoint, limit) {
7
+ // 1. Obtenir file ID del record ODS
8
+ const resp = await fetch(endpoint + (endpoint.includes("?") ? "&" : "?") + "rows=1");
9
+ if (!resp.ok)
10
+ throw new Error(`ODS error ${resp.status}`);
11
+ const odsData = (await resp.json());
12
+ const file = odsData.records?.[0]?.fields?.file;
13
+ if (!file?.id)
14
+ throw new Error("No s'ha trobat el fitxer .pb");
15
+ // 2. Construir URL de descàrrega
16
+ const dsMatch = endpoint.match(/dataset=([^&]+)/);
17
+ const baseMatch = endpoint.match(/(https?:\/\/[^/]+)/);
18
+ if (!dsMatch || !baseMatch)
19
+ throw new Error("No s'ha pogut construir la URL de descàrrega");
20
+ const downloadUrl = `${baseMatch[1]}/explore/dataset/${dsMatch[1]}/files/${file.id}/download/`;
21
+ // 3. Descarregar i decodificar protobuf
22
+ const pbResp = await fetch(downloadUrl);
23
+ if (!pbResp.ok)
24
+ throw new Error(`Error descarregant .pb: ${pbResp.status}`);
25
+ const buf = Buffer.from(await pbResp.arrayBuffer());
26
+ const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(buf);
27
+ // 4. Parsejar entitats segons tipus
28
+ const entities = feed.entity.slice(0, limit);
29
+ if (entities.some((e) => e.tripUpdate)) {
30
+ return {
31
+ type: "trip_updates",
32
+ count: feed.entity.length,
33
+ data: entities.filter((e) => e.tripUpdate).map((e) => {
34
+ const tu = e.tripUpdate;
35
+ const delays = (tu.stopTimeUpdate || []).map((su) => ({
36
+ stop_id: su.stopId,
37
+ arrival_delay_seconds: su.arrival?.delay ?? null,
38
+ departure_delay_seconds: su.departure?.delay ?? null,
39
+ }));
40
+ return {
41
+ trip_id: tu.trip?.tripId ?? null,
42
+ route_id: tu.trip?.routeId ?? null,
43
+ start_date: tu.trip?.startDate ?? null,
44
+ start_time: tu.trip?.startTime ?? null,
45
+ stops: delays,
46
+ max_delay_seconds: Math.max(0, ...delays.map((d) => d.arrival_delay_seconds ?? d.departure_delay_seconds ?? 0)),
47
+ };
48
+ }),
49
+ };
50
+ }
51
+ if (entities.some((e) => e.alert)) {
52
+ return {
53
+ type: "alerts",
54
+ count: feed.entity.length,
55
+ data: entities.filter((e) => e.alert).map((e) => {
56
+ const a = e.alert;
57
+ return {
58
+ header: a.headerText?.translation?.[0]?.text ?? null,
59
+ description: a.descriptionText?.translation?.[0]?.text ?? null,
60
+ cause: a.cause ?? null,
61
+ effect: a.effect ?? null,
62
+ active_periods: (a.activePeriod || []).map((p) => ({
63
+ start: p.start ? new Date(Number(p.start) * 1000).toISOString() : null,
64
+ end: p.end ? new Date(Number(p.end) * 1000).toISOString() : null,
65
+ })),
66
+ routes: (a.informedEntity || []).map((ie) => ie.routeId).filter(Boolean),
67
+ stops: (a.informedEntity || []).map((ie) => ie.stopId).filter(Boolean),
68
+ };
69
+ }),
70
+ };
71
+ }
72
+ if (entities.some((e) => e.vehicle)) {
73
+ return {
74
+ type: "vehicle_positions",
75
+ count: feed.entity.length,
76
+ data: entities.filter((e) => e.vehicle).map((e) => {
77
+ const v = e.vehicle;
78
+ return {
79
+ trip_id: v.trip?.tripId ?? null,
80
+ route_id: v.trip?.routeId ?? null,
81
+ latitude: v.position?.latitude ?? null,
82
+ longitude: v.position?.longitude ?? null,
83
+ speed_kmh: v.position?.speed != null ? Math.round(v.position.speed * 3.6) : null,
84
+ bearing: v.position?.bearing ?? null,
85
+ stop_id: v.stopId ?? null,
86
+ timestamp: v.timestamp ? new Date(Number(v.timestamp) * 1000).toISOString() : null,
87
+ };
88
+ }),
89
+ };
90
+ }
91
+ return { type: "unknown", count: feed.entity.length, data: [] };
92
+ }
package/dist/index.js CHANGED
@@ -10,9 +10,10 @@ import { queryCkan } from "./clients/ckan.js";
10
10
  import { queryDiba } from "./clients/diba.js";
11
11
  import { queryCido } from "./clients/cido.js";
12
12
  import { queryOpendatasoft } from "./clients/opendatasoft.js";
13
+ import { decodeGtfsRt } from "./clients/gtfsrt.js";
13
14
  const server = new McpServer({
14
15
  name: "opendata-cat",
15
- version: "0.0.12",
16
+ version: "0.0.13",
16
17
  });
17
18
  // Tool 1: search_datasets
18
19
  server.tool("search_datasets", "Cerca datasets de dades obertes catalanes per text lliure. Retorna nom, descripció, portal i formats.", {
@@ -92,6 +93,25 @@ server.tool("query_dataset", "Executa una consulta contra un dataset i retorna f
92
93
  }
93
94
  else if (dataset.api_type === "opendatasoft") {
94
95
  const data = await queryOpendatasoft(dataset.api_endpoint, filters, search, limit, offset);
96
+ // Detect and decode GTFS-RT protobuf files
97
+ const first = data.records[0];
98
+ const fileField = first?.file;
99
+ if (fileField?.filename?.endsWith(".pb") || fileField?.filename?.endsWith(".pbf")) {
100
+ const decoded = await decodeGtfsRt(dataset.api_endpoint, limit);
101
+ return {
102
+ content: [{
103
+ type: "text",
104
+ text: JSON.stringify({
105
+ dataset: dataset.name,
106
+ format: "GTFS Realtime",
107
+ type: decoded.type,
108
+ total_entities: decoded.count,
109
+ count: decoded.data.length,
110
+ data: decoded.data,
111
+ }, null, 2),
112
+ }],
113
+ };
114
+ }
95
115
  results = data.records;
96
116
  }
97
117
  else {
@@ -451,7 +471,7 @@ async function main() {
451
471
  // Health check
452
472
  if (req.url === "/health") {
453
473
  res.writeHead(200, { "Content-Type": "application/json" });
454
- res.end(JSON.stringify({ status: "ok", name: "opendata-cat", version: "0.0.12" }));
474
+ res.end(JSON.stringify({ status: "ok", name: "opendata-cat", version: "0.0.13" }));
455
475
  return;
456
476
  }
457
477
  // MCP endpoint
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.0.12",
6
+ "version": "0.0.14",
7
7
  "description": "Servidor MCP per consultar les dades obertes públiques de Catalunya",
8
8
  "type": "module",
9
9
  "main": "dist/index.js",
@@ -36,6 +36,7 @@
36
36
  "homepage": "https://github.com/xaviviro/Opendata.cat-MCP-Server#readme",
37
37
  "dependencies": {
38
38
  "@modelcontextprotocol/sdk": "^1.29.0",
39
+ "gtfs-realtime-bindings": "^1.1.1",
39
40
  "zod": "^4.3.6"
40
41
  },
41
42
  "devDependencies": {