sbb-mcp 0.4.0 → 0.4.2

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/dist/tools.js CHANGED
@@ -1,30 +1,21 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { isSmapiConfigured, searchPlaces, searchTrips, getTrip, paginateTrips, getTripPrices, getTripOffers, mockSearchPlaces, mockSearchTrips, mockGetTripPrices, } from './transport/index.js';
4
- import { formatStations, formatConnections, formatTripDetails, formatPrices, formatTicketLink, buildSbbDeepLink, appendWeatherToConnections, } from './formatters.js';
4
+ import { formatStations, formatConnections, formatTripDetails, formatPrices, formatTicketLink, buildSbbDeepLink, } from './formatters.js';
5
5
  import { cacheGet, cacheSet, TTL } from './cache.js';
6
6
  import { SmapiError } from './transport/smapi-client.js';
7
7
  import { loadProfile, saveProfile, formatProfileSummary, isReductionCardExpired } from './profile.js';
8
8
  import { isSwissTripConfigured, fetchProfile as fetchCloudProfile, fetchTravelers, toSmapiTraveler, formatTravelersList, formatCloudProfile, } from './swisstrip.js';
9
- import { resolveLang } from './i18n.js';
9
+ import { getWeatherSummary } from 'swiss-weather-mcp';
10
+ import { toStationsListDTO, toConnectionListDTO, toTripDetailsDTO, toPricesTableDTO, toTicketCardDTO, } from './structured.js';
11
+ import { registerWidgets, widgetResponseMeta, widgetToolMeta, WIDGETS } from './widgets.js';
10
12
  const useMock = !isSmapiConfigured();
11
13
  const useCloud = isSwissTripConfigured();
12
- const LANG_ENUM = z.enum(['de', 'fr', 'it', 'en', 'es', 'pt', 'ru', 'ar', 'zh']);
13
- const LANG_DESC = "Output language (de/fr/it/en/es/pt/ru/ar/zh). Defaults to the user's saved profile language or English.";
14
- /** Pick the effective language: tool arg > saved profile > English. */
15
- function effectiveLang(arg, profile) {
16
- if (arg)
17
- return resolveLang(arg);
18
- if (profile?.language)
19
- return resolveLang(profile.language);
20
- return 'en';
21
- }
22
14
  /** Match SwissTrip travelers by first name (case-insensitive, fuzzy) */
23
15
  function matchTravelersByName(all, names) {
24
16
  const matched = [];
25
17
  for (const name of names) {
26
18
  const lower = name.toLowerCase();
27
- // Try exact first_name match, then label match, then partial match
28
19
  const found = all.find(t => t.first_name?.toLowerCase() === lower ||
29
20
  t.label?.toLowerCase() === lower) || all.find(t => t.first_name?.toLowerCase().startsWith(lower) ||
30
21
  t.last_name?.toLowerCase() === lower);
@@ -43,12 +34,24 @@ function errorResult(err) {
43
34
  const message = err instanceof Error ? err.message : String(err);
44
35
  return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
45
36
  }
37
+ /** Compute Europe/Zurich ISO timestamp for a (date, HH:MM) pair. */
38
+ function zurichIso(date, time) {
39
+ const probe = new Date(`${date}T${time}:00Z`);
40
+ const zurichStr = probe.toLocaleString('en-US', { timeZone: 'Europe/Zurich' });
41
+ const zurichTime = new Date(zurichStr + ' UTC');
42
+ const offsetMs = zurichTime.getTime() - probe.getTime();
43
+ const offsetHours = Math.round(offsetMs / (60 * 60 * 1000));
44
+ const offsetStr = `${offsetHours >= 0 ? '+' : '-'}${String(Math.abs(offsetHours)).padStart(2, '0')}:00`;
45
+ return new Date(`${date}T${time}:00${offsetStr}`).toISOString();
46
+ }
46
47
  export function createSbbMcpServer() {
47
48
  const server = new McpServer({
48
49
  name: 'sbb-mcp',
49
- version: '0.4.0',
50
+ version: '0.4.2',
50
51
  description: 'Swiss Federal Railways (SBB/CFF/FFS) — real-time train schedules, prices, and ticket purchase links for Switzerland',
51
52
  });
53
+ // ─── Widget resources (ChatGPT Apps SDK) ───────────────────────────────
54
+ registerWidgets(server);
52
55
  // ─── Prompts ───────────────────────────────────────────────────────────
53
56
  server.prompt('plan_journey', 'Plan a train journey in Switzerland — finds connections, compares prices, and provides ticket links', {
54
57
  from: z.string().describe('Origin station (e.g. "Zurich")'),
@@ -68,7 +71,6 @@ export function createSbbMcpServer() {
68
71
  description: 'The user\'s saved travel profile (name, DOB, reduction card). Read this before showing prices or generating ticket links. If empty, ask the user for their details and save with save_profile.',
69
72
  mimeType: 'text/plain',
70
73
  }, async () => {
71
- // Cloud profile takes precedence when SWISSTRIP_TOKEN is set
72
74
  if (useCloud) {
73
75
  try {
74
76
  const cloudProfile = await fetchCloudProfile();
@@ -81,7 +83,7 @@ export function createSbbMcpServer() {
81
83
  };
82
84
  }
83
85
  catch {
84
- // Fall through to local profile on cloud error
86
+ // Fall through
85
87
  }
86
88
  }
87
89
  const profile = loadProfile();
@@ -108,14 +110,18 @@ export function createSbbMcpServer() {
108
110
  };
109
111
  });
110
112
  // ─── Tool: save_profile ──────────────────────────────────────────────
111
- server.tool('save_profile', 'Save the user\'s travel profile locally for future sessions. Call this after asking the user for their details (name, date of birth, reduction card). Data is stored at ~/.sbb-mcp/profile.json.', {
112
- first_name: z.string().optional().describe('First name (for ticket booking)'),
113
- last_name: z.string().optional().describe('Last name (for ticket booking)'),
114
- date_of_birth: z.string().optional().describe('Date of birth YYYY-MM-DD (for age-based pricing)'),
115
- reduction_card: z.enum(['HALF_FARE', 'GA', 'NONE']).optional().describe('Swiss reduction card: HALF_FARE (Halbtax), GA (General Abonnement), or NONE'),
116
- reduction_card_valid_until: z.string().optional().describe('Reduction card expiry date YYYY-MM-DD (ask the user when their Halbtax/GA expires)'),
117
- language: LANG_ENUM.optional().describe('Preferred output language saved as the default for all future tool calls. ' + LANG_DESC),
118
- }, { title: 'Save Travel Profile', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ first_name, last_name, date_of_birth, reduction_card, reduction_card_valid_until, language }) => {
113
+ server.registerTool('save_profile', {
114
+ title: 'Save Travel Profile',
115
+ description: 'Save the user\'s travel profile locally for future sessions. Call this after asking the user for their details (name, date of birth, reduction card). Data is stored at ~/.sbb-mcp/profile.json.',
116
+ inputSchema: {
117
+ first_name: z.string().optional().describe('First name (for ticket booking)'),
118
+ last_name: z.string().optional().describe('Last name (for ticket booking)'),
119
+ date_of_birth: z.string().optional().describe('Date of birth YYYY-MM-DD (for age-based pricing)'),
120
+ reduction_card: z.enum(['HALF_FARE', 'GA', 'NONE']).optional().describe('Swiss reduction card: HALF_FARE (Halbtax), GA (General Abonnement), or NONE'),
121
+ reduction_card_valid_until: z.string().optional().describe('Reduction card expiry date YYYY-MM-DD (ask the user when their Halbtax/GA expires)'),
122
+ },
123
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
124
+ }, async ({ first_name, last_name, date_of_birth, reduction_card, reduction_card_valid_until }) => {
119
125
  try {
120
126
  const updates = {};
121
127
  if (first_name)
@@ -128,40 +134,43 @@ export function createSbbMcpServer() {
128
134
  updates.reduction_card = reduction_card;
129
135
  if (reduction_card_valid_until)
130
136
  updates.reduction_card_valid_until = reduction_card_valid_until;
131
- if (language)
132
- updates.language = language;
133
137
  saveProfile(updates);
134
138
  const profile = loadProfile();
135
- const lang = effectiveLang(language, profile);
136
- return { content: [{ type: 'text', text: formatProfileSummary(profile, lang) }] };
139
+ return { content: [{ type: 'text', text: formatProfileSummary(profile) }] };
137
140
  }
138
141
  catch (err) {
139
142
  return errorResult(err);
140
143
  }
141
144
  });
142
145
  // ─── Tool: get_profile ───────────────────────────────────────────────
143
- server.tool('get_profile', 'Read the user\'s saved travel profile. Check this before showing prices to use correct reduction card. If no profile exists, ask the user for their details.', {
144
- language: LANG_ENUM.optional().describe(LANG_DESC),
145
- }, { title: 'Get Travel Profile', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ language }) => {
146
- // Cloud profile takes precedence
146
+ server.registerTool('get_profile', {
147
+ title: 'Get Travel Profile',
148
+ description: 'Read the user\'s saved travel profile. Check this before showing prices to use correct reduction card. If no profile exists, ask the user for their details.',
149
+ inputSchema: {},
150
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
151
+ }, async () => {
147
152
  if (useCloud) {
148
153
  try {
149
154
  const cloudProfile = await fetchCloudProfile();
150
155
  return { content: [{ type: 'text', text: formatCloudProfile(cloudProfile) }] };
151
156
  }
152
157
  catch {
153
- // Fall through to local
158
+ // Fall through
154
159
  }
155
160
  }
156
161
  const profile = loadProfile();
157
162
  if (!profile) {
158
163
  return { content: [{ type: 'text', text: 'No travel profile saved. Ask the user: "Do you have a Halbtax or GA travelcard?" Then use save_profile to store their details for next time.' }] };
159
164
  }
160
- const lang = effectiveLang(language, profile);
161
- return { content: [{ type: 'text', text: formatProfileSummary(profile, lang) }] };
165
+ return { content: [{ type: 'text', text: formatProfileSummary(profile) }] };
162
166
  });
163
167
  // ─── Tool: list_travelers (SwissTrip cloud only) ─────────────────────
164
- server.tool('list_travelers', 'List all travelers in the user\'s SwissTrip account (self, partner, kids). Each traveler has their own reduction card. Use their names with get_prices for family trip pricing. Requires SWISSTRIP_TOKEN.', {}, { title: 'List Travelers', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
168
+ server.registerTool('list_travelers', {
169
+ title: 'List Travelers',
170
+ description: 'List all travelers in the user\'s SwissTrip account (self, partner, kids). Each traveler has their own reduction card. Use their names with get_prices for family trip pricing. Requires SWISSTRIP_TOKEN.',
171
+ inputSchema: {},
172
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
173
+ }, async () => {
165
174
  if (!useCloud) {
166
175
  return { content: [{ type: 'text', text: 'list_travelers requires a SwissTrip account connection (SWISSTRIP_TOKEN). Without it, use save_profile/get_profile for single-traveler local profiles.' }] };
167
176
  }
@@ -172,7 +181,7 @@ export function createSbbMcpServer() {
172
181
  return { content: [{ type: 'text', text: cached }] };
173
182
  const travelers = await fetchTravelers();
174
183
  const text = formatTravelersList(travelers);
175
- cacheSet(cacheKey, text, TTL.STATIONS); // ~1h cache
184
+ cacheSet(cacheKey, text, TTL.STATIONS);
176
185
  return { content: [{ type: 'text', text }] };
177
186
  }
178
187
  catch (err) {
@@ -180,45 +189,51 @@ export function createSbbMcpServer() {
180
189
  }
181
190
  });
182
191
  // ─── Tool 1: search_stations ──────────────────────────────────────────
183
- server.tool('search_stations', 'Search for Swiss train stations, addresses, or points of interest by name. Returns station IDs needed for other tools.', {
184
- query: z.string().describe('Station name to search for (e.g. "Zurich", "Bern", "Interlaken")'),
185
- limit: z.number().min(1).max(20).default(10).describe('Maximum number of results'),
186
- language: LANG_ENUM.optional().describe(LANG_DESC),
187
- }, { title: 'Search Stations', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ query, limit, language }) => {
192
+ server.registerTool('search_stations', {
193
+ title: 'Search Stations',
194
+ description: 'Search for Swiss train stations, addresses, or points of interest by name. Returns station IDs needed for other tools.',
195
+ inputSchema: {
196
+ query: z.string().describe('Station name to search for (e.g. "Zurich", "Bern", "Interlaken")'),
197
+ limit: z.number().min(1).max(20).default(10).describe('Maximum number of results'),
198
+ },
199
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
200
+ _meta: widgetToolMeta(WIDGETS.STATIONS_LIST, 'Searching stations…', 'Stations ready'),
201
+ }, async ({ query, limit }) => {
188
202
  try {
189
- const lang = effectiveLang(language, loadProfile());
190
- const cacheKey = `stations:${query.toLowerCase()}:${limit}:${lang}`;
191
- const cached = cacheGet(cacheKey);
192
- if (cached)
193
- return { content: [{ type: 'text', text: cached }] };
194
203
  const stations = useMock
195
204
  ? await mockSearchPlaces({ name: query })
196
- : await searchPlaces({ name: query, type: 'STOP', numberOfResults: limit, lang });
197
- const text = formatStations(stations, lang);
198
- cacheSet(cacheKey, text, TTL.STATIONS);
199
- return { content: [{ type: 'text', text }] };
205
+ : await searchPlaces({ name: query, type: 'STOP', numberOfResults: limit });
206
+ return {
207
+ content: [{ type: 'text', text: formatStations(stations) }],
208
+ structuredContent: toStationsListDTO(query, stations),
209
+ _meta: widgetResponseMeta(WIDGETS.STATIONS_LIST),
210
+ };
200
211
  }
201
212
  catch (err) {
202
213
  return errorResult(err);
203
214
  }
204
215
  });
205
216
  // ─── Tool 2: search_connections ───────────────────────────────────────
206
- server.tool('search_connections', 'Find train connections between two Swiss stations. Returns schedules with departure/arrival times, duration, transfers, and trip IDs for pricing.', {
207
- from: z.string().describe('Origin station name or ID (e.g. "Zurich HB" or "8503000")'),
208
- to: z.string().describe('Destination station name or ID (e.g. "Bern" or "8507000")'),
209
- date: z.string().optional().describe('Travel date in YYYY-MM-DD format (default: today)'),
210
- time: z.string().optional().describe('Departure time in HH:MM format (default: now)'),
211
- arrival_time: z.boolean().optional().describe('If true, the time parameter is treated as desired arrival time'),
212
- language: LANG_ENUM.optional().describe(LANG_DESC),
213
- }, { title: 'Search Connections', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ from, to, date, time, arrival_time, language }) => {
217
+ server.registerTool('search_connections', {
218
+ title: 'Search Connections',
219
+ description: 'Find train connections between two Swiss stations. Returns schedules with departure/arrival times, duration, transfers, and trip IDs for pricing.',
220
+ inputSchema: {
221
+ from: z.string().describe('Origin station name or ID (e.g. "Zurich HB" or "8503000")'),
222
+ to: z.string().describe('Destination station name or ID (e.g. "Bern" or "8507000")'),
223
+ date: z.string().optional().describe('Travel date in YYYY-MM-DD format (default: today)'),
224
+ time: z.string().optional().describe('Departure time in HH:MM format (default: now)'),
225
+ arrival_time: z.boolean().optional().describe('If true, the time parameter is treated as desired arrival time'),
226
+ },
227
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
228
+ _meta: widgetToolMeta(WIDGETS.CONNECTION_LIST, 'Finding trains…', 'Connections ready'),
229
+ }, async ({ from, to, date, time, arrival_time }) => {
214
230
  try {
215
- const lang = effectiveLang(language, loadProfile());
216
231
  let fromId = from;
217
232
  let toId = to;
218
233
  if (!/^\d{5,8}$/.test(from)) {
219
234
  const stations = useMock
220
235
  ? await mockSearchPlaces({ name: from })
221
- : await searchPlaces({ name: from, type: 'STOP', numberOfResults: 1, lang });
236
+ : await searchPlaces({ name: from, type: 'STOP', numberOfResults: 1 });
222
237
  if (stations.length === 0) {
223
238
  return { content: [{ type: 'text', text: `No station found matching "${from}". Try a different name.` }] };
224
239
  }
@@ -227,7 +242,7 @@ export function createSbbMcpServer() {
227
242
  if (!/^\d{5,8}$/.test(to)) {
228
243
  const stations = useMock
229
244
  ? await mockSearchPlaces({ name: to })
230
- : await searchPlaces({ name: to, type: 'STOP', numberOfResults: 1, lang });
245
+ : await searchPlaces({ name: to, type: 'STOP', numberOfResults: 1 });
231
246
  if (stations.length === 0) {
232
247
  return { content: [{ type: 'text', text: `No station found matching "${to}". Try a different name.` }] };
233
248
  }
@@ -238,25 +253,12 @@ export function createSbbMcpServer() {
238
253
  if (date || time) {
239
254
  const d = date || new Date().toISOString().split('T')[0];
240
255
  const t = time || '08:00';
241
- // Compute correct Europe/Zurich offset (CET +01:00 or CEST +02:00)
242
- const probe = new Date(`${d}T${t}:00Z`);
243
- const zurichStr = probe.toLocaleString('en-US', { timeZone: 'Europe/Zurich' });
244
- const zurichTime = new Date(zurichStr + ' UTC');
245
- const offsetMs = zurichTime.getTime() - probe.getTime();
246
- const offsetHours = Math.round(offsetMs / (60 * 60 * 1000));
247
- const offsetStr = `${offsetHours >= 0 ? '+' : '-'}${String(Math.abs(offsetHours)).padStart(2, '0')}:00`;
248
- const dt = new Date(`${d}T${t}:00${offsetStr}`).toISOString();
249
- if (arrival_time) {
256
+ const dt = zurichIso(d, t);
257
+ if (arrival_time)
250
258
  arrivalTime = dt;
251
- }
252
- else {
259
+ else
253
260
  departureTime = dt;
254
- }
255
261
  }
256
- const cacheKey = `connections:${fromId}:${toId}:${departureTime || ''}:${arrivalTime || ''}:${lang}`;
257
- const cached = cacheGet(cacheKey);
258
- if (cached)
259
- return { content: [{ type: 'text', text: cached }] };
260
262
  const collection = useMock
261
263
  ? await mockSearchTrips({ origin: fromId, destination: toId, departureTime })
262
264
  : await searchTrips({
@@ -264,129 +266,149 @@ export function createSbbMcpServer() {
264
266
  destination: toId,
265
267
  ...(departureTime && { departureTime }),
266
268
  ...(arrivalTime && { arrivalTime }),
267
- lang,
268
269
  });
269
- let text = formatConnections(collection, lang);
270
- // Append destination weather if coordinates available
270
+ let text = formatConnections(collection);
271
+ let weatherSummary;
271
272
  if (collection.trips.length > 0) {
272
273
  const dest = collection.trips[0].destination;
273
274
  if (dest.geoPosition) {
274
275
  const travelDate = date || new Date().toISOString().split('T')[0];
275
- text = await appendWeatherToConnections(text, dest.geoPosition.latitude, dest.geoPosition.longitude, travelDate, dest.name || to);
276
+ const name = dest.name || to;
277
+ try {
278
+ const summary = await getWeatherSummary(dest.geoPosition.latitude, dest.geoPosition.longitude, travelDate);
279
+ if (summary) {
280
+ weatherSummary = summary;
281
+ text = text + `\n\n**${name} weather:** ${summary}`;
282
+ }
283
+ }
284
+ catch {
285
+ // Non-fatal
286
+ }
276
287
  }
277
288
  }
278
- cacheSet(cacheKey, text, TTL.CONNECTIONS);
279
- return { content: [{ type: 'text', text }] };
289
+ return {
290
+ content: [{ type: 'text', text }],
291
+ structuredContent: toConnectionListDTO(collection, weatherSummary ? { summary: weatherSummary } : undefined),
292
+ _meta: widgetResponseMeta(WIDGETS.CONNECTION_LIST),
293
+ };
280
294
  }
281
295
  catch (err) {
282
296
  return errorResult(err);
283
297
  }
284
298
  });
285
299
  // ─── Tool 3: get_trip_details ─────────────────────────────────────────
286
- server.tool('get_trip_details', 'Get detailed information about a specific train connection including all intermediate stops, platforms, and occupancy. Use a trip ID from search_connections results.', {
287
- trip_id: z.string().describe('Trip ID from search_connections results'),
288
- language: LANG_ENUM.optional().describe(LANG_DESC),
289
- }, { title: 'Get Trip Details', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ trip_id, language }) => {
300
+ server.registerTool('get_trip_details', {
301
+ title: 'Get Trip Details',
302
+ description: 'Get detailed information about a specific train connection including all intermediate stops, platforms, and occupancy. Use a trip ID from search_connections results.',
303
+ inputSchema: {
304
+ trip_id: z.string().describe('Trip ID from search_connections results'),
305
+ },
306
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
307
+ _meta: widgetToolMeta(WIDGETS.TRIP_DETAILS, 'Loading trip details…', 'Trip details ready'),
308
+ }, async ({ trip_id }) => {
290
309
  try {
291
- const lang = effectiveLang(language, loadProfile());
292
310
  if (useMock) {
293
311
  return { content: [{ type: 'text', text: 'Detailed trip information is available with live API access. In mock mode, use search_connections to see trip summaries.' }] };
294
312
  }
295
- const cacheKey = `trip:${trip_id}:${lang}`;
296
- const cached = cacheGet(cacheKey);
297
- if (cached)
298
- return { content: [{ type: 'text', text: cached }] };
299
- const trip = await getTrip(trip_id, 'REAL_BOARDING_ALIGHTING', lang);
300
- const text = formatTripDetails(trip, lang);
301
- cacheSet(cacheKey, text, TTL.TRIP_DETAILS);
302
- return { content: [{ type: 'text', text }] };
313
+ const trip = await getTrip(trip_id, 'REAL_BOARDING_ALIGHTING');
314
+ return {
315
+ content: [{ type: 'text', text: formatTripDetails(trip) }],
316
+ structuredContent: toTripDetailsDTO(trip),
317
+ _meta: widgetResponseMeta(WIDGETS.TRIP_DETAILS),
318
+ };
303
319
  }
304
320
  catch (err) {
305
321
  return errorResult(err);
306
322
  }
307
323
  });
308
324
  // ─── Tool 4: get_more_connections ─────────────────────────────────────
309
- server.tool('get_more_connections', 'Load earlier or later train connections for a previous search. Use the collection ID from search_connections results.', {
310
- collection_id: z.string().describe('Collection ID from search_connections results'),
311
- direction: z.enum(['next', 'previous']).describe('"next" for later trains, "previous" for earlier trains'),
312
- language: LANG_ENUM.optional().describe(LANG_DESC),
313
- }, { title: 'Get More Connections', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ collection_id, direction, language }) => {
325
+ server.registerTool('get_more_connections', {
326
+ title: 'Get More Connections',
327
+ description: 'Load earlier or later train connections for a previous search. Use the collection ID from search_connections results.',
328
+ inputSchema: {
329
+ collection_id: z.string().describe('Collection ID from search_connections results'),
330
+ direction: z.enum(['next', 'previous']).describe('"next" for later trains, "previous" for earlier trains'),
331
+ },
332
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
333
+ _meta: widgetToolMeta(WIDGETS.CONNECTION_LIST, 'Loading more trains…', 'More connections ready'),
334
+ }, async ({ collection_id, direction }) => {
314
335
  try {
315
- const lang = effectiveLang(language, loadProfile());
316
- if (useMock) {
317
- const offset = direction === 'next' ? 3 * 60 * 60 * 1000 : -3 * 60 * 60 * 1000;
318
- const baseTime = new Date(Date.now() + offset).toISOString();
319
- const collection = await mockSearchTrips({ origin: '8503000', destination: '8507000', departureTime: baseTime });
320
- return { content: [{ type: 'text', text: formatConnections(collection, lang) }] };
321
- }
322
- const collection = await paginateTrips(collection_id, direction, lang);
323
- return { content: [{ type: 'text', text: formatConnections(collection, lang) }] };
336
+ const collection = useMock
337
+ ? await mockSearchTrips({
338
+ origin: '8503000',
339
+ destination: '8507000',
340
+ departureTime: new Date(Date.now() + (direction === 'next' ? 3 : -3) * 60 * 60 * 1000).toISOString(),
341
+ })
342
+ : await paginateTrips(collection_id, direction);
343
+ return {
344
+ content: [{ type: 'text', text: formatConnections(collection) }],
345
+ structuredContent: toConnectionListDTO(collection),
346
+ _meta: widgetResponseMeta(WIDGETS.CONNECTION_LIST),
347
+ };
324
348
  }
325
349
  catch (err) {
326
350
  return errorResult(err);
327
351
  }
328
352
  });
329
353
  // ─── Tool 5: get_prices ───────────────────────────────────────────────
330
- server.tool('get_prices', 'Get ticket prices for one or more train connections. Supports Half-Fare card (Halbtax) and GA travelcard discounts. When connected to SwissTrip (SWISSTRIP_TOKEN), pass traveler_names to get family pricing for multiple travelers.', {
331
- trip_ids: z.array(z.string()).min(1).max(10).describe('Trip IDs from search_connections results'),
332
- traveler_type: z.enum(['ADULT', 'CHILD']).default('ADULT').describe('Traveler type (used when no traveler_names given)'),
333
- reduction_card: z.enum(['HALF_FARE', 'GA', 'NONE']).default('HALF_FARE').describe('Swiss reduction card (used when no traveler_names given)'),
334
- traveler_names: z.array(z.string()).optional().describe('Names of SwissTrip travelers to price for (e.g. ["Fabian", "Anna"]). Requires SWISSTRIP_TOKEN. Each traveler\'s reduction card is applied automatically.'),
335
- language: LANG_ENUM.optional().describe(LANG_DESC),
336
- }, { title: 'Get Prices', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ trip_ids, traveler_type, reduction_card, traveler_names, language }) => {
354
+ server.registerTool('get_prices', {
355
+ title: 'Get Prices',
356
+ description: 'Get ticket prices for one or more train connections. Supports Half-Fare card (Halbtax) and GA travelcard discounts. When connected to SwissTrip (SWISSTRIP_TOKEN), pass traveler_names to get family pricing for multiple travelers.',
357
+ inputSchema: {
358
+ trip_ids: z.array(z.string()).min(1).max(10).describe('Trip IDs from search_connections results'),
359
+ traveler_type: z.enum(['ADULT', 'CHILD']).default('ADULT').describe('Traveler type (used when no traveler_names given)'),
360
+ reduction_card: z.enum(['HALF_FARE', 'GA', 'NONE']).default('HALF_FARE').describe('Swiss reduction card (used when no traveler_names given)'),
361
+ traveler_names: z.array(z.string()).optional().describe('Names of SwissTrip travelers to price for (e.g. ["Fabian", "Anna"]). Requires SWISSTRIP_TOKEN. Each traveler\'s reduction card is applied automatically.'),
362
+ },
363
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
364
+ _meta: widgetToolMeta(WIDGETS.PRICES_TABLE, 'Fetching prices…', 'Prices ready'),
365
+ }, async ({ trip_ids, traveler_type, reduction_card, traveler_names }) => {
337
366
  try {
338
- const lang = effectiveLang(language, loadProfile());
339
- // Build travelers list — cloud multi-traveler or single manual
340
367
  let travelers;
341
- let cacheKey;
342
368
  if (traveler_names && traveler_names.length > 0 && useCloud) {
343
- // Fetch travelers from SwissTrip and match by name
344
369
  const allTravelers = await fetchTravelers();
345
370
  const matched = matchTravelersByName(allTravelers, traveler_names);
346
371
  if (matched.length === 0) {
347
372
  return { content: [{ type: 'text', text: `No matching travelers found. Available: ${allTravelers.map(t => t.first_name).filter(Boolean).join(', ')}. Use list_travelers to see all.` }] };
348
373
  }
349
374
  travelers = matched.map((t, i) => toSmapiTraveler(t, i));
350
- cacheKey = `prices:${trip_ids.join(',')}:cloud:${matched.map(t => t.id).join(',')}:${lang}`;
351
375
  }
352
376
  else {
353
377
  travelers = [{ id: 'traveler-1', type: traveler_type, reductionCard: reduction_card }];
354
- cacheKey = `prices:${trip_ids.join(',')}:${traveler_type}:${reduction_card}:${lang}`;
355
378
  }
356
- const cached = cacheGet(cacheKey);
357
- if (cached)
358
- return { content: [{ type: 'text', text: cached }] };
359
- if (useMock) {
360
- const prices = await mockGetTripPrices(trip_ids);
361
- const text = formatPrices(prices, lang);
362
- cacheSet(cacheKey, text, TTL.PRICES);
363
- return { content: [{ type: 'text', text }] };
364
- }
365
- const prices = await getTripPrices(trip_ids, travelers, lang);
366
- const text = formatPrices(prices, lang);
367
- cacheSet(cacheKey, text, TTL.PRICES);
368
- return { content: [{ type: 'text', text }] };
379
+ const prices = useMock
380
+ ? await mockGetTripPrices(trip_ids)
381
+ : await getTripPrices(trip_ids, travelers);
382
+ return {
383
+ content: [{ type: 'text', text: formatPrices(prices) }],
384
+ structuredContent: toPricesTableDTO(prices),
385
+ _meta: widgetResponseMeta(WIDGETS.PRICES_TABLE),
386
+ };
369
387
  }
370
388
  catch (err) {
371
389
  return errorResult(err);
372
390
  }
373
391
  });
374
392
  // ─── Tool 6: get_ticket_link ──────────────────────────────────────────
375
- server.tool('get_ticket_link', 'Get a direct purchase link to buy a train ticket on SBB.ch. Only call this when the user wants to buy a specific ticket. On mobile with SBB app installed, opens directly in the app with Halbtax/GA applied automatically. Pass traveler_names for family tickets when connected to SwissTrip.', {
376
- trip_id: z.string().describe('Trip ID to purchase'),
377
- from_name: z.string().describe('Origin station name (e.g. "Zürich HB")'),
378
- from_id: z.string().describe('Origin station ID (e.g. "8503000")'),
379
- to_name: z.string().describe('Destination station name (e.g. "Bern")'),
380
- to_id: z.string().describe('Destination station ID (e.g. "8507000")'),
381
- date: z.string().describe('Travel date YYYY-MM-DD'),
382
- time: z.string().describe('Departure time HH:MM'),
383
- traveler_type: z.enum(['ADULT', 'CHILD']).default('ADULT').describe('Traveler type (used when no traveler_names given)'),
384
- reduction_card: z.enum(['HALF_FARE', 'GA', 'NONE']).default('HALF_FARE').describe('Swiss reduction card (used when no traveler_names given)'),
385
- traveler_names: z.array(z.string()).optional().describe('SwissTrip traveler names for family tickets. Requires SWISSTRIP_TOKEN.'),
386
- language: LANG_ENUM.optional().describe(LANG_DESC),
387
- }, { title: 'Get Ticket Link', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ trip_id, from_name, from_id, to_name, to_id, date, time, traveler_type, reduction_card, traveler_names, language }) => {
393
+ server.registerTool('get_ticket_link', {
394
+ title: 'Get Ticket Link',
395
+ description: 'Get a direct purchase link to buy a train ticket on SBB.ch. Only call this when the user wants to buy a specific ticket. On mobile with SBB app installed, opens directly in the app with Halbtax/GA applied automatically. Pass traveler_names for family tickets when connected to SwissTrip.',
396
+ inputSchema: {
397
+ trip_id: z.string().describe('Trip ID to purchase'),
398
+ from_name: z.string().describe('Origin station name (e.g. "Zürich HB")'),
399
+ from_id: z.string().describe('Origin station ID (e.g. "8503000")'),
400
+ to_name: z.string().describe('Destination station name (e.g. "Bern")'),
401
+ to_id: z.string().describe('Destination station ID (e.g. "8507000")'),
402
+ date: z.string().describe('Travel date YYYY-MM-DD'),
403
+ time: z.string().describe('Departure time HH:MM'),
404
+ traveler_type: z.enum(['ADULT', 'CHILD']).default('ADULT').describe('Traveler type (used when no traveler_names given)'),
405
+ reduction_card: z.enum(['HALF_FARE', 'GA', 'NONE']).default('HALF_FARE').describe('Swiss reduction card (used when no traveler_names given)'),
406
+ traveler_names: z.array(z.string()).optional().describe('SwissTrip traveler names for family tickets. Requires SWISSTRIP_TOKEN.'),
407
+ },
408
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
409
+ _meta: widgetToolMeta(WIDGETS.TICKET_CARD, 'Preparing ticket link…', 'Ticket link ready'),
410
+ }, async ({ trip_id, from_name, from_id, to_name, to_id, date, time, traveler_type, reduction_card, traveler_names }) => {
388
411
  try {
389
- const lang = effectiveLang(language, loadProfile());
390
412
  const sbbLink = buildSbbDeepLink({
391
413
  fromName: from_name,
392
414
  fromId: from_id,
@@ -394,9 +416,7 @@ export function createSbbMcpServer() {
394
416
  toId: to_id,
395
417
  date,
396
418
  time,
397
- lang,
398
419
  });
399
- // Build travelers — cloud multi-traveler or single manual
400
420
  let travelers;
401
421
  if (traveler_names && traveler_names.length > 0 && useCloud) {
402
422
  const allTravelers = await fetchTravelers();
@@ -408,18 +428,32 @@ export function createSbbMcpServer() {
408
428
  else {
409
429
  travelers = [{ id: 'traveler-1', type: traveler_type, reductionCard: reduction_card }];
410
430
  }
411
- // Try to get affiliate deep link from SMAPI (if live)
412
431
  let affiliateLink;
413
432
  if (!useMock) {
414
433
  try {
415
- const result = await getTripOffers(trip_id, travelers, lang);
434
+ const result = await getTripOffers(trip_id, travelers);
416
435
  affiliateLink = result.affiliateDeepLink;
417
436
  }
418
437
  catch {
419
- // Affiliate link is optional — SBB direct link always works
438
+ // Non-fatal
420
439
  }
421
440
  }
422
- return { content: [{ type: 'text', text: formatTicketLink(trip_id, affiliateLink, sbbLink, lang) }] };
441
+ const depIso = zurichIso(date, time);
442
+ return {
443
+ content: [{ type: 'text', text: formatTicketLink(trip_id, affiliateLink, sbbLink) }],
444
+ structuredContent: toTicketCardDTO({
445
+ tripId: trip_id,
446
+ fromName: from_name,
447
+ fromId: from_id,
448
+ toName: to_name,
449
+ toId: to_id,
450
+ departureTime: depIso,
451
+ arrivalTime: depIso,
452
+ primaryLink: sbbLink,
453
+ affiliateLink,
454
+ }),
455
+ _meta: widgetResponseMeta(WIDGETS.TICKET_CARD),
456
+ };
423
457
  }
424
458
  catch (err) {
425
459
  return errorResult(err);