@transit-se/mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Rafael Belliard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # @transit-se/mcp
2
+
3
+ MCP (Model Context Protocol) server for Swedish public transit. Exposes [Trafiklab](https://www.trafiklab.se/) real-time APIs as tools that AI assistants like Claude can call directly.
4
+
5
+ Built on top of [`@transit-se/sdk`](../sdk/).
6
+
7
+ ## Table of Contents
8
+
9
+ - [Quick Start](#quick-start)
10
+ - [Available Tools](#available-tools)
11
+ - [SL tools (no API key required)](#sl-tools-no-api-key-required)
12
+ - [Trafiklab tools (require `TRAFIKLAB_API_KEY`)](#trafiklab-tools-require-trafiklab_api_key)
13
+ - [GTFS tools (require `TRAFIKLAB_GTFS_KEY`)](#gtfs-tools-require-trafiklab_gtfs_key)
14
+ - [Combined tools (require `TRAFIKLAB_GTFS_KEY`)](#combined-tools-require-trafiklab_gtfs_key)
15
+ - [SDK methods not exposed as MCP tools](#sdk-methods-not-exposed-as-mcp-tools)
16
+ - [Typical workflow](#typical-workflow)
17
+ - [Environment Variables](#environment-variables)
18
+ - [Development](#development)
19
+ - [How It Works](#how-it-works)
20
+ - [Project Structure](#project-structure)
21
+ - [License](#license)
22
+
23
+ ---
24
+
25
+ ## Quick Start
26
+
27
+ ### 1. Get API keys (optional)
28
+
29
+ Some tools require [Trafiklab](https://developer.trafiklab.se) API keys. Stockholm (SL) tools work without any keys.
30
+
31
+ 1. Sign up at [developer.trafiklab.se](https://developer.trafiklab.se)
32
+ 2. Create a project
33
+ 3. Enable the API products you need:
34
+ - **Trafiklab Realtime APIs** → for `trafiklab_search_stops`, `trafiklab_get_departures`, `trafiklab_get_arrivals`
35
+ - **GTFS Sweden 3 Realtime** → for `gtfs_service_alerts`, `gtfs_trip_updates`, `gtfs_vehicle_positions`, `combined_nearby_vehicles`
36
+ 4. Copy each API key
37
+
38
+ ### 2. Try it out
39
+
40
+ To interactively test tools in a web UI, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
41
+
42
+ ```bash
43
+ # From the repo root
44
+ bun run --filter @transit-se/mcp inspect
45
+ ```
46
+
47
+ ### 3. Configure your MCP client
48
+
49
+ Add the server to your MCP client configuration. Both keys are optional — without them, only the SL tools (Stockholm) are available.
50
+
51
+ #### Via npx (recommended)
52
+
53
+ No installation needed — `npx` downloads and runs the package automatically. Works with any MCP client that supports the `command` + `args` format.
54
+
55
+ - **Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
56
+ - **Claude Code** — add to `.mcp.json` in your project root or `~/.claude/settings.json`.
57
+ - **Cursor** — add to `.cursor/mcp.json` in your project root:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "transit-se": {
63
+ "command": "npx",
64
+ "args": ["-y", "@transit-se/mcp"],
65
+ "env": {
66
+ "TRAFIKLAB_API_KEY": "your-key-here",
67
+ "TRAFIKLAB_GTFS_KEY": "your-key-here"
68
+ }
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ #### From source (for development)
75
+
76
+ If you've cloned the repo and want to run from source:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "transit-se": {
82
+ "command": "node",
83
+ "args": ["/absolute/path/to/packages/mcp/dist/index.js"],
84
+ "env": {
85
+ "TRAFIKLAB_API_KEY": "your-key-here",
86
+ "TRAFIKLAB_GTFS_KEY": "your-key-here"
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ > Build first with `bun run --filter @transit-se/mcp build`.
94
+
95
+ ---
96
+
97
+ ## Available Tools
98
+
99
+ ### SL tools (no API key required)
100
+
101
+ | Tool | Description | Key Parameters |
102
+ | --------------- | ------------------------------ | ------------------------------------------------------------------------- |
103
+ | `sl_departures` | Stockholm (SL) departures | `site_id`: SL site ID (e.g. 9192) |
104
+ | `sl_sites` | Search SL stations (cached) | `query?`: station name |
105
+ | `sl_deviations` | Service disruptions and alerts | `transport_modes?`: e.g. `["METRO"]`, `line_ids?`, `site_ids?`, `future?` |
106
+
107
+ ### Trafiklab tools (require `TRAFIKLAB_API_KEY`)
108
+
109
+ | Tool | Description | Key Parameters |
110
+ | -------------------------- | -------------------- | --------------------------------------------- |
111
+ | `trafiklab_search_stops` | Search stops by name | `query`: stop name (e.g. "T-Centralen") |
112
+ | `trafiklab_get_departures` | Real-time departures | `area_id`: stop ID, `time?`: YYYY-MM-DDTHH:mm |
113
+ | `trafiklab_get_arrivals` | Real-time arrivals | `area_id`: stop ID, `time?`: YYYY-MM-DDTHH:mm |
114
+
115
+ ### GTFS tools (require `TRAFIKLAB_GTFS_KEY`)
116
+
117
+ | Tool | Description | Key Parameters |
118
+ | ------------------------ | -------------------------------------------------------- | ---------------------------------------------------- |
119
+ | `gtfs_service_alerts` | Service alerts for any Swedish operator (GTFS-RT) | `operator`: e.g. `"ul"` (Uppsala), `"skane"` (Skåne) |
120
+ | `gtfs_trip_updates` | Real-time trip delays and cancellations for any operator | `operator`: e.g. `"ul"` (Uppsala), `"skane"` (Skåne) |
121
+ | `gtfs_vehicle_positions` | Real-time GPS positions for vehicles in service | `operator`: e.g. `"ul"` (Uppsala), `"skane"` (Skåne) |
122
+
123
+ > **Note:** For Stockholm (SL) disruptions, prefer `sl_deviations` — it provides richer data and requires no API key.
124
+
125
+ ### Combined tools (require `TRAFIKLAB_GTFS_KEY`)
126
+
127
+ | Tool | Description | Key Parameters |
128
+ | -------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
129
+ | `combined_nearby_vehicles` | Find vehicles near a Stockholm location with transport mode classification | `site_name?`, `site_id?`, `latitude?`+`longitude?`, `radius_km?` (0–20, default 1.0) |
130
+
131
+ > Combines GTFS-RT vehicle positions with SL stop point data to classify each vehicle as metro, bus, tram, train, ferry, or ship.
132
+ >
133
+ > **Location resolution** — provide exactly one of these (if multiple are given, precedence is `site_id` > `site_name` > `latitude`+`longitude`):
134
+ >
135
+ > - `site_name` — SL station name (e.g. `"Solna centrum"`), resolved via cached site data
136
+ > - `site_id` — SL site ID (e.g. `9305`), use `sl_sites` to find IDs
137
+ > - `latitude` + `longitude` — raw coordinates; the nearest SL site is auto-detected
138
+
139
+ ### SDK methods not exposed as MCP tools
140
+
141
+ The following SDK methods are available for programmatic use but intentionally excluded from the MCP server. They return bulk data that is useful for apps (UI lists, caches) but wasteful for AI agents, which reason better with focused, filtered results.
142
+
143
+ | SDK Method | Why not exposed |
144
+ | ------------------------------ | --------------------------------------------------------------------------- |
145
+ | `sl.getLines()` | Returns all SL lines. Departures already include line info per station. |
146
+ | `sl.getSites()` | Full site response. `sl_sites` uses the cached/lightweight version instead. |
147
+ | `sl.getStopPoints()` | Bulk stop point data. Useful for maps, not for agent queries. |
148
+ | `sl.getTransportAuthorities()` | Admin metadata. No agent use case. |
149
+ | `stops.listAll()` | Returns thousands of stops. Use `trafiklab_search_stops` to search by name. |
150
+
151
+ ### Typical workflow
152
+
153
+ The AI will typically chain tools like this:
154
+
155
+ 1. **User**: "When does the next train leave from Slussen?"
156
+ 2. **AI calls** `sl_sites` with `query: "Slussen"` → gets site ID 9192
157
+ 3. **AI calls** `sl_departures` with `site_id: 9192` → gets real-time departures
158
+ 4. **AI responds** with formatted departure information
159
+
160
+ ---
161
+
162
+ ## Environment Variables
163
+
164
+ | Variable | Trafiklab product | Required | Description |
165
+ | -------------------- | ----------------------- | -------- | ------------------------------------------------------------------------------------------ |
166
+ | `TRAFIKLAB_API_KEY` | Trafiklab Realtime APIs | No | Stop lookup, departures, arrivals. Without it, only the SL tools work. |
167
+ | `TRAFIKLAB_GTFS_KEY` | GTFS Sweden 3 Realtime | No | GTFS-RT feeds (service alerts, trip updates, vehicle positions, nearby vehicles). |
168
+ | `TRAFIKLAB_VALIDATE` | — | No | Set to `"true"` to enable runtime response validation via Valibot schemas. Off by default. |
169
+
170
+ ### Without API Keys
171
+
172
+ If no API keys are set, the server still starts and registers all tools. The 3 SL tools work normally. The Trafiklab and GTFS tools return helpful messages explaining how to get and configure keys.
173
+
174
+ ---
175
+
176
+ ## Development
177
+
178
+ ### Build
179
+
180
+ The MCP server is compiled to JavaScript for Node.js compatibility, so it can be run via `npx` without requiring Bun.
181
+
182
+ ```bash
183
+ # Build the MCP package (compiles src/ → dist/)
184
+ bun run --filter @transit-se/mcp build
185
+
186
+ # Build is also run automatically before npm publish via prepublishOnly
187
+ ```
188
+
189
+ ### Type-check
190
+
191
+ ```bash
192
+ # Type-check just the MCP package
193
+ bun run --filter @transit-se/mcp tc
194
+
195
+ # Type-check everything (SDK + MCP)
196
+ bun run tc
197
+ ```
198
+
199
+ ### Run tests
200
+
201
+ ```bash
202
+ # All tests (SDK + MCP)
203
+ bun test
204
+
205
+ # MCP tests only
206
+ bun test packages/mcp/
207
+ ```
208
+
209
+ ### Lint
210
+
211
+ ```bash
212
+ bun run lint
213
+ ```
214
+
215
+ ### Format
216
+
217
+ ```bash
218
+ bun run format
219
+ ```
220
+
221
+ ---
222
+
223
+ ## How It Works
224
+
225
+ ```
226
+ ┌─────────────────┐ stdio ┌──────────────────┐ HTTPS ┌──────────────┐
227
+ │ Claude / IDE │ ◄──────────► │ @transit-se/mcp │ ──────────► │ Trafiklab │
228
+ │ (MCP Client) │ JSON-RPC │ (this package) │ REST API │ APIs │
229
+ └─────────────────┘ └──────────────────┘ └──────────────┘
230
+
231
+
232
+ @transit-se/sdk
233
+ (TransitClient)
234
+ ```
235
+
236
+ The MCP server is a thin bridge between AI assistants and the transit APIs:
237
+
238
+ 1. **MCP Client** (Claude, Cursor, etc.) discovers the available tools via the MCP protocol
239
+ 2. **Tool calls** arrive as JSON-RPC messages over stdin
240
+ 3. **This server** validates parameters (via Zod schemas), calls the appropriate SDK method, and formats the response as human-readable text
241
+ 4. **Formatted results** go back to the AI, which uses them to answer the user
242
+
243
+ ### Why formatted text instead of raw JSON?
244
+
245
+ AI assistants work better with concise, structured text than massive JSON blobs. The formatters turn API responses into clean departure boards and station lists that the model can reason about efficiently.
246
+
247
+ ---
248
+
249
+ ## Project Structure
250
+
251
+ ```
252
+ packages/mcp/
253
+ ├── src/
254
+ │ ├── index.ts # Server entry — creates McpServer, registers tools, connects stdio
255
+ │ ├── index.test.ts # Server integration tests
256
+ │ ├── formatting.ts # Human-readable output formatters
257
+ │ ├── formatting.test.ts # Formatter tests
258
+ │ └── tools/
259
+ │ ├── sl/
260
+ │ │ ├── transport.ts # sl_departures, sl_sites
261
+ │ │ ├── transport.test.ts
262
+ │ │ ├── deviations.ts # sl_deviations
263
+ │ │ └── deviations.test.ts
264
+ │ ├── trafiklab/
265
+ │ │ ├── stop-lookup.ts # trafiklab_search_stops
266
+ │ │ ├── stop-lookup.test.ts
267
+ │ │ ├── timetables.ts # trafiklab_get_departures, trafiklab_get_arrivals
268
+ │ │ └── timetables.test.ts
269
+ │ ├── gtfs/
270
+ │ │ ├── operators.ts # Shared GTFS operator list (re-exported from SDK)
271
+ │ │ ├── service-alerts.ts # gtfs_service_alerts
272
+ │ │ ├── service-alerts.test.ts
273
+ │ │ ├── trip-updates.ts # gtfs_trip_updates
274
+ │ │ ├── trip-updates.test.ts
275
+ │ │ ├── vehicle-positions.ts # gtfs_vehicle_positions
276
+ │ │ └── vehicle-positions.test.ts
277
+ │ └── combined/
278
+ │ ├── nearby-vehicles.ts # combined_nearby_vehicles
279
+ │ └── nearby-vehicles.test.ts
280
+ ├── package.json
281
+ ├── tsconfig.json
282
+ └── README.md # This file
283
+ ```
284
+
285
+ ---
286
+
287
+ ## License
288
+
289
+ MIT
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Human-readable formatters for SDK responses.
3
+ *
4
+ * These turn raw API data into concise text that AI assistants can
5
+ * reason about efficiently. Raw JSON is large and noisy — formatted
6
+ * text lets the model focus on the information that matters.
7
+ */
8
+ import { GTFS_OPERATOR_NAMES } from '@transit-se/sdk/gtfs';
9
+ // ─── Helpers ────────────────────────────────────────────────────────
10
+ function time(iso) {
11
+ return new Date(iso).toLocaleTimeString('sv-SE', {
12
+ hour: '2-digit',
13
+ minute: '2-digit',
14
+ });
15
+ }
16
+ function delayTag(seconds) {
17
+ if (seconds <= 0) {
18
+ return '';
19
+ }
20
+ const mins = Math.round(seconds / 60);
21
+ return ` (+${mins} min late)`;
22
+ }
23
+ // ─── Stop Lookup ────────────────────────────────────────────────────
24
+ export function formatTrafiklabStopLookup(res) {
25
+ if (res.stop_groups.length === 0) {
26
+ return 'No stops found.';
27
+ }
28
+ const lines = [`Found ${res.stop_groups.length} stop(s):\n`];
29
+ for (const group of res.stop_groups) {
30
+ lines.push(` ${group.name} (ID: ${group.id})`);
31
+ lines.push(` Type: ${group.area_type}`);
32
+ lines.push(` Modes: ${group.transport_modes.join(', ')}`);
33
+ lines.push(` Avg. daily departures: ${group.average_daily_stop_times}`);
34
+ if (group.stops.length > 0) {
35
+ lines.push(` Child stops:`);
36
+ for (const stop of group.stops) {
37
+ lines.push(` - ${stop.name} (${stop.id}) @ ${stop.lat}, ${stop.lon}`);
38
+ }
39
+ }
40
+ lines.push('');
41
+ }
42
+ return lines.join('\n');
43
+ }
44
+ // ─── Timetable Departures / Arrivals ────────────────────────────────
45
+ function formatCallAtLocation(entry, _label) {
46
+ const sched = time(entry.scheduled);
47
+ const real = time(entry.realtime);
48
+ const delay = delayTag(entry.delay);
49
+ const canceled = entry.canceled ? ' [CANCELED]' : '';
50
+ const rt = entry.is_realtime ? '' : ' (scheduled only)';
51
+ const platform = entry.realtime_platform?.designation ?? entry.scheduled_platform?.designation;
52
+ const platStr = platform ? ` Platform ${platform}` : '';
53
+ const alerts = entry.alerts.length > 0
54
+ ? '\n Alerts: ' + entry.alerts.map((a) => a.header).join('; ')
55
+ : '';
56
+ const routeName = entry.route.name ? `${entry.route.name} | ` : '';
57
+ return (` ${real}${delay}${canceled} Line ${entry.route.designation} → ${entry.route.direction}` +
58
+ `${platStr}${rt}` +
59
+ `\n ${routeName}Scheduled: ${sched} | ${entry.stop.name}` +
60
+ alerts);
61
+ }
62
+ export function formatTrafiklabDepartures(res) {
63
+ if (res.departures.length === 0) {
64
+ return `No departures found for stop ${res.query.query}.`;
65
+ }
66
+ const stopName = res.stops[0]?.name ?? res.query.query;
67
+ const lines = [
68
+ `Departures from ${stopName} (${res.departures.length} results):\n`,
69
+ ];
70
+ // Show stop-level alerts
71
+ for (const stop of res.stops) {
72
+ for (const alert of stop.alerts) {
73
+ lines.push(` ⚠ ${alert.header}: ${alert.details}`);
74
+ }
75
+ }
76
+ for (const dep of res.departures) {
77
+ lines.push(formatCallAtLocation(dep, 'departs'));
78
+ }
79
+ return lines.join('\n');
80
+ }
81
+ export function formatTrafiklabArrivals(res) {
82
+ if (res.arrivals.length === 0) {
83
+ return `No arrivals found for stop ${res.query.query}.`;
84
+ }
85
+ const stopName = res.stops[0]?.name ?? res.query.query;
86
+ const lines = [`Arrivals at ${stopName} (${res.arrivals.length} results):\n`];
87
+ for (const stop of res.stops) {
88
+ for (const alert of stop.alerts) {
89
+ lines.push(` ⚠ ${alert.header}: ${alert.details}`);
90
+ }
91
+ }
92
+ for (const arr of res.arrivals) {
93
+ lines.push(formatCallAtLocation(arr, 'arrives'));
94
+ }
95
+ return lines.join('\n');
96
+ }
97
+ // ─── SL Departures ──────────────────────────────────────────────────
98
+ export function formatSLDepartures(res, siteId) {
99
+ if (res.departures.length === 0) {
100
+ return `No departures found for SL site ${siteId}.`;
101
+ }
102
+ const stopName = res.departures[0]?.stop_area.name ?? `site ${siteId}`;
103
+ const lines = [
104
+ `SL Departures from ${stopName} (${res.departures.length} results):\n`,
105
+ ];
106
+ // Stop-level deviations
107
+ for (const dev of res.stop_deviations) {
108
+ lines.push(` ⚠ ${dev.message}`);
109
+ }
110
+ for (const dep of res.departures) {
111
+ const mode = dep.line.transport_mode.toUpperCase();
112
+ const deviations = dep.deviations.length > 0
113
+ ? '\n Disruptions: ' + dep.deviations.map((d) => d.message).join('; ')
114
+ : '';
115
+ const passenger = dep.journey.passenger_level !== 'UNKNOWN' ? ` Crowding: ${dep.journey.passenger_level}` : '';
116
+ lines.push(` ${dep.display.padEnd(8)} ${mode} ${dep.line.designation} → ${dep.destination}` +
117
+ ` (Platform ${dep.stop_point.designation})${passenger}` +
118
+ deviations);
119
+ }
120
+ return lines.join('\n');
121
+ }
122
+ // ─── SL Sites (cached) ─────────────────────────────────────────────
123
+ export function formatSLSites(sites, query) {
124
+ if (sites.length === 0) {
125
+ return query ? `No SL sites matching "${query}".` : 'No SL sites found.';
126
+ }
127
+ const header = query
128
+ ? `SL sites matching "${query}" (${sites.length} results):`
129
+ : `All SL sites (${sites.length} total):`;
130
+ const lines = [header, ''];
131
+ const display = sites.slice(0, 100); // cap to avoid huge output
132
+ for (const site of display) {
133
+ lines.push(` ${site.name} (ID: ${site.id}) @ ${site.lat}, ${site.lon}`);
134
+ }
135
+ if (sites.length > 100) {
136
+ lines.push(`\n ... and ${sites.length - 100} more. Use a search query to narrow results.`);
137
+ }
138
+ return lines.join('\n');
139
+ }
140
+ // ─── SL Deviations ──────────────────────────────────────────────────
141
+ export function formatSLDeviations(messages, context) {
142
+ if (messages.length === 0) {
143
+ return context
144
+ ? `No service deviations found for ${context}.`
145
+ : 'No active service deviations.';
146
+ }
147
+ const header = context
148
+ ? `Service deviations for ${context} (${messages.length}):`
149
+ : `Service deviations (${messages.length}):`;
150
+ const lines = [header, ''];
151
+ for (const msg of messages) {
152
+ const variant = msg.message_variants[0];
153
+ if (!variant) {
154
+ continue;
155
+ }
156
+ const affectedLines = msg.scope.lines?.map((l) => `${l.name} ${l.designation}`).join(', ') ?? '';
157
+ const affectedStops = msg.scope.stop_areas?.map((s) => s.name).join(', ') ?? '';
158
+ const until = new Date(msg.publish.upto).toLocaleDateString('sv-SE');
159
+ const categoryTags = msg.categories && msg.categories.length > 0
160
+ ? ` [${msg.categories.map((c) => c.type).join(', ')}]`
161
+ : '';
162
+ lines.push(` ${variant.header}${categoryTags}`);
163
+ lines.push(` ${variant.details.replace(/\n+/g, ' ').trim()}`);
164
+ if (affectedStops) {
165
+ lines.push(` Affects: ${affectedStops}`);
166
+ }
167
+ if (affectedLines) {
168
+ lines.push(` Lines: ${affectedLines}`);
169
+ }
170
+ lines.push(` Until: ${until}`);
171
+ lines.push('');
172
+ }
173
+ return lines.join('\n').trimEnd();
174
+ }
175
+ // ─── GTFS Trip Updates ──────────────────────────────────────────────
176
+ export function formatGtfsTripUpdates(updates, operator) {
177
+ const operatorName = GTFS_OPERATOR_NAMES[operator] ?? operator;
178
+ if (updates.length === 0) {
179
+ return `No active trip updates for ${operatorName}.`;
180
+ }
181
+ const lines = [`Trip updates for ${operatorName} (${updates.length}):\n`];
182
+ for (const tu of updates) {
183
+ const route = tu.trip.routeId ? `Route ${tu.trip.routeId}` : 'Unknown route';
184
+ const tripId = tu.trip.tripId ? ` (trip ${tu.trip.tripId})` : '';
185
+ const status = tu.trip.scheduleRelationship !== 'SCHEDULED' ? ` [${tu.trip.scheduleRelationship}]` : '';
186
+ const delayStr = tu.delay != null ? ` — ${tu.delay > 0 ? '+' : ''}${Math.round(tu.delay / 60)} min` : '';
187
+ lines.push(` ${route}${tripId}${status}${delayStr}`);
188
+ if (tu.vehicle) {
189
+ const vLabel = tu.vehicle.label ?? tu.vehicle.id ?? 'unknown';
190
+ lines.push(` Vehicle: ${vLabel}`);
191
+ }
192
+ if (tu.trip.startTime) {
193
+ const date = tu.trip.startDate
194
+ ? `${tu.trip.startDate.slice(0, 4)}-${tu.trip.startDate.slice(4, 6)}-${tu.trip.startDate.slice(6, 8)} `
195
+ : '';
196
+ lines.push(` Departure: ${date}${tu.trip.startTime}`);
197
+ }
198
+ if (tu.stopTimeUpdates.length > 0) {
199
+ const skipped = tu.stopTimeUpdates.filter((s) => s.scheduleRelationship === 'SKIPPED');
200
+ const delayed = tu.stopTimeUpdates.filter((s) => s.scheduleRelationship === 'SCHEDULED' &&
201
+ (s.arrival?.delay ?? s.departure?.delay ?? 0) > 0);
202
+ if (skipped.length > 0) {
203
+ const stopIds = skipped.map((s) => s.stopId ?? `#${s.stopSequence}`).join(', ');
204
+ lines.push(` Skipped stops: ${stopIds}`);
205
+ }
206
+ if (delayed.length > 0) {
207
+ const maxDelay = Math.max(...delayed.map((s) => Math.max(s.arrival?.delay ?? 0, s.departure?.delay ?? 0)));
208
+ lines.push(` ${delayed.length} stop(s) delayed, max +${Math.round(maxDelay / 60)} min`);
209
+ }
210
+ const nextStop = tu.stopTimeUpdates.find((s) => s.scheduleRelationship === 'SCHEDULED');
211
+ if (nextStop) {
212
+ const stopLabel = nextStop.stopId ?? `stop #${nextStop.stopSequence}`;
213
+ const arrDelay = nextStop.arrival?.delay;
214
+ const depDelay = nextStop.departure?.delay;
215
+ const stopDelay = arrDelay ?? depDelay;
216
+ const stopDelayStr = stopDelay != null
217
+ ? ` (${stopDelay > 0 ? '+' : ''}${Math.round(stopDelay / 60)} min)`
218
+ : '';
219
+ lines.push(` Next: ${stopLabel}${stopDelayStr}`);
220
+ }
221
+ }
222
+ lines.push('');
223
+ }
224
+ return lines.join('\n').trimEnd();
225
+ }
226
+ // ─── GTFS Vehicle Positions ─────────────────────────────────────────
227
+ export function formatGtfsVehiclePositions(positions, operator) {
228
+ const operatorName = GTFS_OPERATOR_NAMES[operator] ?? operator;
229
+ if (positions.length === 0) {
230
+ return `No active vehicles for ${operatorName}.`;
231
+ }
232
+ const lines = [`Vehicle positions for ${operatorName} (${positions.length}):\n`];
233
+ for (const vp of positions) {
234
+ const route = vp.trip?.routeId ? `Route ${vp.trip.routeId}` : 'Unknown route';
235
+ const vehicleLabel = vp.vehicle?.label ?? vp.vehicle?.id ?? 'unknown vehicle';
236
+ const status = vp.currentStatus ? ` [${vp.currentStatus.replace(/_/g, ' ')}]` : '';
237
+ lines.push(` ${route} — ${vehicleLabel}${status}`);
238
+ if (vp.position) {
239
+ const bearing = vp.position.bearing != null ? ` bearing ${vp.position.bearing}°` : '';
240
+ const speed = vp.position.speed != null ? ` at ${Math.round(vp.position.speed * 3.6)} km/h` : '';
241
+ lines.push(` Position: ${vp.position.latitude.toFixed(4)}, ${vp.position.longitude.toFixed(4)}${bearing}${speed}`);
242
+ }
243
+ if (vp.stopId) {
244
+ lines.push(` Stop: ${vp.stopId}${vp.currentStopSequence != null ? ` (seq ${vp.currentStopSequence})` : ''}`);
245
+ }
246
+ if (vp.occupancyStatus && vp.occupancyStatus !== 'NO_DATA_AVAILABLE') {
247
+ const pct = vp.occupancyPercentage != null ? ` (${vp.occupancyPercentage}%)` : '';
248
+ lines.push(` Occupancy: ${vp.occupancyStatus.replace(/_/g, ' ').toLowerCase()}${pct}`);
249
+ }
250
+ if (vp.congestionLevel && vp.congestionLevel !== 'UNKNOWN_CONGESTION_LEVEL') {
251
+ lines.push(` Congestion: ${vp.congestionLevel.replace(/_/g, ' ').toLowerCase()}`);
252
+ }
253
+ lines.push('');
254
+ }
255
+ return lines.join('\n').trimEnd();
256
+ }
257
+ // ─── GTFS Service Alerts ────────────────────────────────────────────
258
+ export function formatGtfsServiceAlerts(alerts, operator) {
259
+ const operatorName = GTFS_OPERATOR_NAMES[operator] ?? operator;
260
+ if (alerts.length === 0) {
261
+ return `No active service alerts for ${operatorName}.`;
262
+ }
263
+ const lines = [`Service alerts for ${operatorName} (${alerts.length}):\n`];
264
+ for (const alert of alerts) {
265
+ const header = alert.headerText ?? '(no title)';
266
+ const effect = alert.effect !== 'UNKNOWN_EFFECT' ? ` [${alert.effect}]` : '';
267
+ const cause = alert.cause !== 'UNKNOWN_CAUSE' ? ` (${alert.cause})` : '';
268
+ lines.push(` ${header}${effect}`);
269
+ if (alert.descriptionText) {
270
+ lines.push(` ${alert.descriptionText.replace(/\n+/g, ' ').trim()}`);
271
+ }
272
+ if (alert.cause !== 'UNKNOWN_CAUSE') {
273
+ lines.push(` Cause: ${alert.cause.replace(/_/g, ' ').toLowerCase()}${cause ? '' : ''}`);
274
+ }
275
+ const entities = alert.informedEntities;
276
+ if (entities.length > 0) {
277
+ const routes = entities
278
+ .filter((e) => !!e.routeId)
279
+ .map((e) => e.routeId);
280
+ const stops = entities
281
+ .filter((e) => !!e.stopId)
282
+ .map((e) => e.stopId);
283
+ if (routes.length > 0) {
284
+ lines.push(` Routes: ${[...new Set(routes)].join(', ')}`);
285
+ }
286
+ if (stops.length > 0) {
287
+ lines.push(` Stops: ${[...new Set(stops)].join(', ')}`);
288
+ }
289
+ }
290
+ if (alert.activePeriods.length > 0) {
291
+ const period = alert.activePeriods[0];
292
+ const from = period.start ? new Date(period.start * 1000).toLocaleDateString('sv-SE') : '?';
293
+ const to = period.end ? new Date(period.end * 1000).toLocaleDateString('sv-SE') : 'ongoing';
294
+ lines.push(` Period: ${from} → ${to}`);
295
+ }
296
+ if (alert.url) {
297
+ lines.push(` Info: ${alert.url}`);
298
+ }
299
+ lines.push('');
300
+ }
301
+ return lines.join('\n').trimEnd();
302
+ }
303
+ // ─── Combined SL Nearby Vehicles ────────────────────────────────────
304
+ export function formatCombinedSLNearbyVehicles(result) {
305
+ const { location, radiusKm, vehicles, activeModes } = result;
306
+ if (vehicles.length === 0) {
307
+ return `No vehicles found within ${radiusKm} km of ${location.name}.`;
308
+ }
309
+ const lines = [
310
+ `Vehicles near ${location.name} (ID: ${location.siteId}) — ${vehicles.length} within ${radiusKm} km`,
311
+ `Active modes: ${activeModes.join(', ')}`,
312
+ '',
313
+ ];
314
+ for (const v of vehicles) {
315
+ const mode = v.transportMode.toUpperCase().padEnd(7);
316
+ const dist = v.distanceMeters < 1000
317
+ ? `${v.distanceMeters} m`
318
+ : `${(v.distanceMeters / 1000).toFixed(2)} km`;
319
+ const label = v.vehicleLabel ?? v.vehicleId ?? '—';
320
+ const speed = v.position.speed != null ? `${Math.round(v.position.speed * 3.6)} km/h` : '—';
321
+ const bearing = v.position.bearing != null ? `${v.position.bearing}°` : '—';
322
+ const status = v.currentStatus?.replace(/_/g, ' ') ?? '—';
323
+ const ts = v.timestamp
324
+ ? new Date(v.timestamp * 1000).toLocaleTimeString('sv-SE', {
325
+ hour: '2-digit',
326
+ minute: '2-digit',
327
+ })
328
+ : '—';
329
+ const trip = v.trip?.tripId ? `trip ${v.trip.tripId}` : '';
330
+ const stopPt = v.nearestStopPoint
331
+ ? `near ${v.nearestStopPoint.name}${v.nearestStopPoint.designation ? ` (${v.nearestStopPoint.designation})` : ''}`
332
+ : '';
333
+ lines.push(` ${mode} ${dist.padEnd(8)} ${label}` +
334
+ ` ${status} speed ${speed} bearing ${bearing} updated ${ts}`);
335
+ const details = [stopPt, trip].filter(Boolean);
336
+ if (details.length > 0) {
337
+ lines.push(` ${details.join(' | ')}`);
338
+ }
339
+ }
340
+ return lines.join('\n');
341
+ }
package/dist/index.js ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @transit-se/mcp — MCP server for Swedish public transit.
4
+ *
5
+ * This is the entry point. It:
6
+ * 1. Reads the TRAFIKLAB_API_KEY from the environment (passed via MCP config)
7
+ * 2. Creates a TransitClient from @transit-se/sdk
8
+ * 3. Registers all tools with the MCP server
9
+ * 4. Connects via stdio transport so Claude / Cursor / any MCP client can call the tools
10
+ *
11
+ * Run it directly:
12
+ * TRAFIKLAB_API_KEY=xxx bun run packages/mcp/src/index.ts
13
+ *
14
+ * Or configure it in your MCP client — see README.md for details.
15
+ */
16
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
+ import { TransitClient } from '@transit-se/sdk';
19
+ import { CombinedSLNearbyVehiclesApi } from '@transit-se/sdk/combined';
20
+ import { GtfsServiceAlertsApi, GtfsTripUpdatesApi, GtfsVehiclePositionsApi, } from '@transit-se/sdk/gtfs';
21
+ import { SLDeviationsApi, SLTransportApi } from '@transit-se/sdk/sl';
22
+ import pkg from '../package.json';
23
+ import { registerCombinedSLNearbyVehiclesTools } from './tools/combined/nearby-vehicles.js';
24
+ import { registerGtfsServiceAlertsTools } from './tools/gtfs/service-alerts.js';
25
+ import { registerGtfsTripUpdatesTools } from './tools/gtfs/trip-updates.js';
26
+ import { registerGtfsVehiclePositionsTools } from './tools/gtfs/vehicle-positions.js';
27
+ import { registerSLDeviationsTools } from './tools/sl/deviations.js';
28
+ import { registerSLTransportTools } from './tools/sl/transport.js';
29
+ import { registerTrafiklabStopLookupTools } from './tools/trafiklab/stop-lookup.js';
30
+ import { registerTrafiklabTimetableTools } from './tools/trafiklab/timetables.js';
31
+ // ─── Configuration ──────────────────────────────────────────────────
32
+ const API_KEY = process.env.TRAFIKLAB_API_KEY;
33
+ const GTFS_KEY = process.env.TRAFIKLAB_GTFS_KEY;
34
+ const VALIDATE = process.env.TRAFIKLAB_VALIDATE === 'true';
35
+ // ─── SDK Clients ────────────────────────────────────────────────────
36
+ // SL Transport API works without a key — always available
37
+ const sl = new SLTransportApi({ validate: VALIDATE });
38
+ // SL Deviations API works without a key — always available
39
+ const deviationsApi = new SLDeviationsApi({ validate: VALIDATE });
40
+ // Full TransitClient requires an API key. If missing, we still start
41
+ // the server but only register the SL tools (which are keyless).
42
+ const client = API_KEY ? new TransitClient({ apiKey: API_KEY, validate: VALIDATE }) : null;
43
+ // GTFS-RT APIs require a separate GTFS Sweden 3 key.
44
+ const gtfsAlerts = GTFS_KEY ? new GtfsServiceAlertsApi({ apiKey: GTFS_KEY }) : null;
45
+ const gtfsTripUpdates = GTFS_KEY ? new GtfsTripUpdatesApi({ apiKey: GTFS_KEY }) : null;
46
+ const gtfsVehiclePositions = GTFS_KEY ? new GtfsVehiclePositionsApi({ apiKey: GTFS_KEY }) : null;
47
+ // Combined APIs merge GTFS + SL data (needs GTFS key for vehicle positions).
48
+ const combinedNearbyVehicles = gtfsVehiclePositions
49
+ ? new CombinedSLNearbyVehiclesApi({
50
+ vehiclePositionsApi: gtfsVehiclePositions,
51
+ slTransportApi: sl,
52
+ })
53
+ : null;
54
+ // ─── MCP Server ─────────────────────────────────────────────────────
55
+ const server = new McpServer({
56
+ name: pkg.name,
57
+ version: pkg.version,
58
+ });
59
+ // Always register SL tools (no key needed)
60
+ registerSLTransportTools(server, sl);
61
+ registerSLDeviationsTools(server, deviationsApi);
62
+ if (client) {
63
+ // Full API key available — register all tools
64
+ registerTrafiklabStopLookupTools(server, client);
65
+ registerTrafiklabTimetableTools(server, client);
66
+ }
67
+ else {
68
+ // No API key — register placeholder tools that explain the situation
69
+ const noKeyMessage = [
70
+ 'This tool requires a TRAFIKLAB_API_KEY which is not configured.',
71
+ '',
72
+ 'To fix this, add the key to your MCP server configuration:',
73
+ '',
74
+ ' "env": { "TRAFIKLAB_API_KEY": "your-key-here" }',
75
+ '',
76
+ 'Get a free key at: https://developer.trafiklab.se',
77
+ ' 1. Create an account',
78
+ ' 2. Create a project',
79
+ ' 3. Enable "Trafiklab Realtime APIs"',
80
+ ' 4. Copy the API key',
81
+ '',
82
+ 'Meanwhile, the sl_departures, sl_sites, and sl_deviations tools work without a key.',
83
+ ].join('\n');
84
+ for (const [name, desc] of [
85
+ [
86
+ 'trafiklab_search_stops',
87
+ 'Search for Swedish public transport stops by name. (Requires TRAFIKLAB_API_KEY)',
88
+ ],
89
+ [
90
+ 'trafiklab_get_departures',
91
+ 'Get real-time departures from a Swedish transit stop. (Requires TRAFIKLAB_API_KEY)',
92
+ ],
93
+ [
94
+ 'trafiklab_get_arrivals',
95
+ 'Get real-time arrivals at a Swedish transit stop. (Requires TRAFIKLAB_API_KEY)',
96
+ ],
97
+ ]) {
98
+ server.tool(name, desc, async () => ({
99
+ content: [{ type: 'text', text: noKeyMessage }],
100
+ }));
101
+ }
102
+ }
103
+ // GTFS-RT tools (separate key)
104
+ if (gtfsAlerts && gtfsTripUpdates && gtfsVehiclePositions) {
105
+ registerGtfsServiceAlertsTools(server, gtfsAlerts);
106
+ registerGtfsTripUpdatesTools(server, gtfsTripUpdates);
107
+ registerGtfsVehiclePositionsTools(server, gtfsVehiclePositions);
108
+ if (combinedNearbyVehicles) {
109
+ registerCombinedSLNearbyVehiclesTools(server, combinedNearbyVehicles);
110
+ }
111
+ }
112
+ else {
113
+ const noGtfsKeyMessage = [
114
+ 'This tool requires a TRAFIKLAB_GTFS_KEY which is not configured.',
115
+ '',
116
+ 'To fix this, add the key to your MCP server configuration:',
117
+ '',
118
+ ' "env": { "TRAFIKLAB_GTFS_KEY": "your-key-here" }',
119
+ '',
120
+ 'Get a free key at: https://developer.trafiklab.se',
121
+ ' 1. Create an account',
122
+ ' 2. Create a project',
123
+ ' 3. Enable "GTFS Sweden 3" (under GTFS Datasets)',
124
+ ' 4. Copy the API key',
125
+ '',
126
+ 'For Stockholm disruptions, sl_deviations works without any key.',
127
+ ].join('\n');
128
+ for (const [name, desc] of [
129
+ [
130
+ 'gtfs_service_alerts',
131
+ 'Get service alerts for Swedish transit operators (Requires TRAFIKLAB_GTFS_KEY)',
132
+ ],
133
+ [
134
+ 'gtfs_trip_updates',
135
+ 'Get real-time trip updates for Swedish transit operators (Requires TRAFIKLAB_GTFS_KEY)',
136
+ ],
137
+ [
138
+ 'gtfs_vehicle_positions',
139
+ 'Get real-time vehicle positions for Swedish transit operators (Requires TRAFIKLAB_GTFS_KEY)',
140
+ ],
141
+ [
142
+ 'combined_nearby_vehicles',
143
+ 'Find vehicles near a Stockholm location with transport mode (Requires TRAFIKLAB_GTFS_KEY)',
144
+ ],
145
+ ]) {
146
+ server.tool(name, desc, async () => ({
147
+ content: [{ type: 'text', text: noGtfsKeyMessage }],
148
+ }));
149
+ }
150
+ }
151
+ // ─── Connect ────────────────────────────────────────────────────────
152
+ const transport = new StdioServerTransport();
153
+ await server.connect(transport);
@@ -0,0 +1,33 @@
1
+ /**
2
+ * MCP tool for finding vehicles near a Stockholm location.
3
+ *
4
+ * Combines GTFS-RT vehicle positions with SL stop point data to
5
+ * return a table of nearby vehicles classified by transport mode
6
+ * (metro, bus, tram, etc.).
7
+ *
8
+ * Requires TRAFIKLAB_GTFS_KEY.
9
+ */
10
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
11
+ import { z } from 'zod';
12
+ import { formatCombinedSLNearbyVehicles } from '../../formatting.js';
13
+ export function registerCombinedSLNearbyVehiclesTools(server, nearbyVehiclesApi) {
14
+ const desc = API_DESCRIPTIONS.combined_nearby_vehicles;
15
+ server.tool('combined_nearby_vehicles', getApiDescription(desc), {
16
+ site_name: z.string().optional().describe(desc.params.site_name),
17
+ site_id: z.number().optional().describe(desc.params.site_id),
18
+ latitude: z.number().optional().describe(desc.params.latitude),
19
+ longitude: z.number().optional().describe(desc.params.longitude),
20
+ radius_km: z.number().min(0).max(20).optional().describe(desc.params.radius_km),
21
+ }, async ({ site_name, site_id, latitude, longitude, radius_km }) => {
22
+ const result = await nearbyVehiclesApi.getNearbyVehicles({
23
+ siteName: site_name,
24
+ siteId: site_id,
25
+ latitude,
26
+ longitude,
27
+ radiusKm: radius_km,
28
+ });
29
+ return {
30
+ content: [{ type: 'text', text: formatCombinedSLNearbyVehicles(result) }],
31
+ };
32
+ });
33
+ }
@@ -0,0 +1 @@
1
+ export { GTFS_OPERATORS } from '@transit-se/sdk/gtfs';
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MCP tool for GTFS-RT ServiceAlerts.
3
+ *
4
+ * Exposes service alerts from GTFS Sweden 3 feeds, covering all Swedish
5
+ * transit operators with real-time data (UL, Skånetrafiken, etc.).
6
+ *
7
+ * For Stockholm (SL) disruptions, the sl_deviations tool is preferred —
8
+ * it provides richer data and requires no API key.
9
+ *
10
+ * Requires TRAFIKLAB_GTFS_KEY.
11
+ */
12
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
13
+ import { z } from 'zod';
14
+ import { formatGtfsServiceAlerts } from '../../formatting.js';
15
+ import { GTFS_OPERATORS } from './operators.js';
16
+ export function registerGtfsServiceAlertsTools(server, alertsApi) {
17
+ server.tool('gtfs_service_alerts', getApiDescription(API_DESCRIPTIONS.gtfs_service_alerts), {
18
+ operator: z
19
+ .enum(GTFS_OPERATORS)
20
+ .describe(API_DESCRIPTIONS.gtfs_service_alerts.params.operator),
21
+ }, async ({ operator }) => {
22
+ const result = await alertsApi.getServiceAlerts(operator);
23
+ return {
24
+ content: [{ type: 'text', text: formatGtfsServiceAlerts(result, operator) }],
25
+ };
26
+ });
27
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * MCP tool for GTFS-RT TripUpdates.
3
+ *
4
+ * Exposes real-time trip updates from GTFS Sweden 3 feeds, covering all
5
+ * Swedish transit operators with real-time data (UL, Skånetrafiken, etc.).
6
+ *
7
+ * Provides per-trip delay predictions, cancellations, and per-stop
8
+ * arrival/departure times. Updated every 15 seconds.
9
+ *
10
+ * Requires TRAFIKLAB_GTFS_KEY.
11
+ */
12
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
13
+ import { z } from 'zod';
14
+ import { formatGtfsTripUpdates } from '../../formatting.js';
15
+ import { GTFS_OPERATORS } from './operators.js';
16
+ export function registerGtfsTripUpdatesTools(server, tripUpdatesApi) {
17
+ server.tool('gtfs_trip_updates', getApiDescription(API_DESCRIPTIONS.gtfs_trip_updates), {
18
+ operator: z.enum(GTFS_OPERATORS).describe(API_DESCRIPTIONS.gtfs_trip_updates.params.operator),
19
+ }, async ({ operator }) => {
20
+ const result = await tripUpdatesApi.getTripUpdates(operator);
21
+ return {
22
+ content: [{ type: 'text', text: formatGtfsTripUpdates(result, operator) }],
23
+ };
24
+ });
25
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * MCP tool for GTFS-RT VehiclePositions.
3
+ *
4
+ * Exposes real-time vehicle positions from GTFS Sweden 3 feeds, covering
5
+ * all Swedish transit operators with real-time data. Returns GPS locations,
6
+ * bearing, speed, and occupancy for vehicles currently in service.
7
+ * Updated every 3 seconds.
8
+ *
9
+ * Requires TRAFIKLAB_GTFS_KEY.
10
+ */
11
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
12
+ import { z } from 'zod';
13
+ import { formatGtfsVehiclePositions } from '../../formatting.js';
14
+ import { GTFS_OPERATORS } from './operators.js';
15
+ export function registerGtfsVehiclePositionsTools(server, vehiclePositionsApi) {
16
+ server.tool('gtfs_vehicle_positions', getApiDescription(API_DESCRIPTIONS.gtfs_vehicle_positions), {
17
+ operator: z
18
+ .enum(GTFS_OPERATORS)
19
+ .describe(API_DESCRIPTIONS.gtfs_vehicle_positions.params.operator),
20
+ }, async ({ operator }) => {
21
+ const result = await vehiclePositionsApi.getVehiclePositions(operator);
22
+ return {
23
+ content: [{ type: 'text', text: formatGtfsVehiclePositions(result, operator) }],
24
+ };
25
+ });
26
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * MCP tool for the SL Deviations API.
3
+ *
4
+ * Exposes service alerts and disruptions for Stockholm's transit network.
5
+ * No API key required.
6
+ */
7
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
8
+ import { z } from 'zod';
9
+ import { formatSLDeviations } from '../../formatting.js';
10
+ const TRANSPORT_MODES = ['BUS', 'METRO', 'TRAM', 'TRAIN', 'SHIP', 'FERRY', 'TAXI'];
11
+ export function registerSLDeviationsTools(server, deviations) {
12
+ /**
13
+ * sl_deviations — Active service alerts across SL's network.
14
+ *
15
+ * Returns disruption messages: elevator/escalator outages, track works,
16
+ * cancellations, timetable changes, and other incidents.
17
+ * Filter by transport mode, line ID, or site ID to narrow results.
18
+ */
19
+ server.tool('sl_deviations', getApiDescription(API_DESCRIPTIONS.sl_deviations), {
20
+ transport_modes: z
21
+ .array(z.enum(TRANSPORT_MODES))
22
+ .optional()
23
+ .describe(API_DESCRIPTIONS.sl_deviations.params.transport_modes),
24
+ line_ids: z
25
+ .array(z.string())
26
+ .optional()
27
+ .describe(API_DESCRIPTIONS.sl_deviations.params.line_ids),
28
+ site_ids: z
29
+ .array(z.string())
30
+ .optional()
31
+ .describe(API_DESCRIPTIONS.sl_deviations.params.site_ids),
32
+ future: z.boolean().optional().describe(API_DESCRIPTIONS.sl_deviations.params.future),
33
+ }, async ({ transport_modes, line_ids, site_ids, future }) => {
34
+ const parsedLineIds = line_ids?.map(Number);
35
+ const parsedSiteIds = site_ids?.map(Number);
36
+ const result = await deviations.getDeviations({
37
+ transportModes: transport_modes,
38
+ lineIds: parsedLineIds,
39
+ siteIds: parsedSiteIds,
40
+ future,
41
+ });
42
+ const context = buildContext(transport_modes, parsedLineIds, parsedSiteIds);
43
+ return {
44
+ content: [{ type: 'text', text: formatSLDeviations(result, context) }],
45
+ };
46
+ });
47
+ }
48
+ function buildContext(modes, lineIds, siteIds) {
49
+ const parts = [];
50
+ if (modes && modes.length > 0) {
51
+ parts.push(modes.join('/'));
52
+ }
53
+ if (lineIds && lineIds.length > 0) {
54
+ parts.push(`lines ${lineIds.join(', ')}`);
55
+ }
56
+ if (siteIds && siteIds.length > 0) {
57
+ parts.push(`sites ${siteIds.join(', ')}`);
58
+ }
59
+ return parts.length > 0 ? parts.join(', ') : undefined;
60
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * MCP tools for the SL Transport API (Stockholm-specific).
3
+ *
4
+ * These tools work WITHOUT an API key since the SL Transport API is public.
5
+ * They provide Stockholm-specific data: departures and site lookups.
6
+ *
7
+ * sl_sites fetches from the SL Transport API and caches results in memory
8
+ * for the lifetime of the server — fast lookups after the initial fetch.
9
+ */
10
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
11
+ import { z } from 'zod';
12
+ import { formatSLDepartures, formatSLSites } from '../../formatting.js';
13
+ export function registerSLTransportTools(server, sl) {
14
+ /**
15
+ * sl_departures — Real-time SL departures from a Stockholm station.
16
+ *
17
+ * Includes display times ("3 min"), crowding levels, platform info,
18
+ * and disruption messages. Use sl_sites to find site IDs.
19
+ */
20
+ server.tool('sl_departures', getApiDescription(API_DESCRIPTIONS.sl_departures), {
21
+ site_id: z.string().describe(API_DESCRIPTIONS.sl_departures.params.site_id),
22
+ }, async ({ site_id }) => {
23
+ const id = Number(site_id);
24
+ const result = await sl.getDepartures(id);
25
+ return {
26
+ content: [{ type: 'text', text: formatSLDepartures(result, id) }],
27
+ };
28
+ });
29
+ /**
30
+ * sl_sites — Search SL stations.
31
+ *
32
+ * Fetches from the SL API on first call, then cached. Great for
33
+ * resolving "Slussen" → site ID 9192 before calling sl_departures.
34
+ */
35
+ server.tool('sl_sites', getApiDescription(API_DESCRIPTIONS.sl_sites), {
36
+ query: z.string().optional().describe(API_DESCRIPTIONS.sl_sites.params.query),
37
+ }, async ({ query }) => {
38
+ const sites = query ? await sl.searchSitesByName(query) : await sl.getCachedSites();
39
+ return {
40
+ content: [{ type: 'text', text: formatSLSites(sites, query) }],
41
+ };
42
+ });
43
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MCP tool for the Trafiklab Stop Lookup API.
3
+ *
4
+ * Lets the AI search for Swedish public transport stops by name
5
+ * and discover stop IDs needed by other tools (like trafiklab_get_departures).
6
+ *
7
+ * Requires a TRAFIKLAB_API_KEY.
8
+ */
9
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
10
+ import { z } from 'zod';
11
+ import { formatTrafiklabStopLookup } from '../../formatting.js';
12
+ export function registerTrafiklabStopLookupTools(server, client) {
13
+ /**
14
+ * trafiklab_search_stops — Find stops by name.
15
+ *
16
+ * Use this as the first step when a user mentions a station or stop name.
17
+ * Returns stop IDs that can be passed to trafiklab_get_departures / trafiklab_get_arrivals.
18
+ */
19
+ server.tool('trafiklab_search_stops', getApiDescription(API_DESCRIPTIONS.trafiklab_search_stops), {
20
+ query: z.string().describe(API_DESCRIPTIONS.trafiklab_search_stops.params.query),
21
+ }, async ({ query }) => {
22
+ const result = await client.stops.searchByName(query);
23
+ return {
24
+ content: [{ type: 'text', text: formatTrafiklabStopLookup(result) }],
25
+ };
26
+ });
27
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * MCP tools for the Trafiklab Timetables API.
3
+ *
4
+ * These tools provide real-time departures and arrivals for any Swedish
5
+ * public transport stop. They need a stop area ID — use trafiklab_search_stops
6
+ * first if you only have a station name.
7
+ *
8
+ * Both tools require a TRAFIKLAB_API_KEY.
9
+ */
10
+ import { API_DESCRIPTIONS, getApiDescription } from '@transit-se/sdk';
11
+ import { z } from 'zod';
12
+ import { formatTrafiklabArrivals, formatTrafiklabDepartures } from '../../formatting.js';
13
+ export function registerTrafiklabTimetableTools(server, client) {
14
+ /**
15
+ * trafiklab_get_departures — Real-time departures from a stop.
16
+ *
17
+ * Returns the next 60 minutes of departures including real-time delays,
18
+ * cancellations, platform info, and service alerts.
19
+ */
20
+ server.tool('trafiklab_get_departures', getApiDescription(API_DESCRIPTIONS.trafiklab_get_departures), {
21
+ area_id: z.string().describe(API_DESCRIPTIONS.trafiklab_get_departures.params.area_id),
22
+ time: z.string().optional().describe(API_DESCRIPTIONS.trafiklab_get_departures.params.time),
23
+ }, async ({ area_id, time }) => {
24
+ const result = await client.timetables.getDepartures(area_id, time);
25
+ return {
26
+ content: [{ type: 'text', text: formatTrafiklabDepartures(result) }],
27
+ };
28
+ });
29
+ /**
30
+ * trafiklab_get_arrivals — Real-time arrivals at a stop.
31
+ *
32
+ * Same as trafiklab_get_departures but for incoming vehicles.
33
+ */
34
+ server.tool('trafiklab_get_arrivals', getApiDescription(API_DESCRIPTIONS.trafiklab_get_arrivals), {
35
+ area_id: z.string().describe(API_DESCRIPTIONS.trafiklab_get_arrivals.params.area_id),
36
+ time: z.string().optional().describe(API_DESCRIPTIONS.trafiklab_get_arrivals.params.time),
37
+ }, async ({ area_id, time }) => {
38
+ const result = await client.timetables.getArrivals(area_id, time);
39
+ return {
40
+ content: [{ type: 'text', text: formatTrafiklabArrivals(result) }],
41
+ };
42
+ });
43
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@transit-se/mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Swedish public transit (Trafiklab). Real-time departures, service alerts, and stop lookups as AI-callable tools.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Rafael Belliard",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/rebelliard/transit-se.git",
11
+ "directory": "packages/mcp"
12
+ },
13
+ "scripts": {
14
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
15
+ "prepublishOnly": "bun run build",
16
+ "start": "node dist/index.js",
17
+ "inspect": "set -a && . ../../.env 2>/dev/null; set +a; DANGEROUSLY_OMIT_AUTH=1 npx @modelcontextprotocol/inspector ${TRAFIKLAB_API_KEY:+-e TRAFIKLAB_API_KEY=$TRAFIKLAB_API_KEY} ${TRAFIKLAB_GTFS_KEY:+-e TRAFIKLAB_GTFS_KEY=$TRAFIKLAB_GTFS_KEY} bun run src/index.ts",
18
+ "tc": "tsc --noEmit",
19
+ "test": "bun test",
20
+ "lint": "eslint src/",
21
+ "version:patch": "bun ../../scripts/bump-version.ts patch",
22
+ "version:minor": "bun ../../scripts/bump-version.ts minor",
23
+ "version:major": "bun ../../scripts/bump-version.ts major",
24
+ "version:pre": "bun ../../scripts/bump-version.ts prerelease"
25
+ },
26
+ "main": "dist/index.js",
27
+ "bin": {
28
+ "transit-mcp": "dist/index.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "keywords": [
36
+ "mcp",
37
+ "model-context-protocol",
38
+ "trafiklab",
39
+ "sl",
40
+ "sverige",
41
+ "sweden",
42
+ "stockholm",
43
+ "lokaltrafik"
44
+ ],
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.27.0",
47
+ "@transit-se/sdk": "^1.0.0",
48
+ "zod": "^3.25.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/bun": "^1.3.9"
52
+ }
53
+ }