diff --git a/MCP.md b/MCP.md index cbd924ad..1b7e420b 100644 --- a/MCP.md +++ b/MCP.md @@ -140,13 +140,17 @@ that match your granted scopes for that session. | `vacay:write` | Manage vacation plans | Vacation | | `geo:read` | Maps & geocoding | Geo | | `weather:read` | Weather forecasts | Weather | +| `journey:read` | View journeys | Journey | +| `journey:write` | Manage journeys | Journey | +| `journey:share` | Manage journey share links | Journey | **Scope rules:** - A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access). - Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access. +- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access. - `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools. - Static tokens and web session JWTs have full access to all tools (equivalent to all scopes). -- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled. +- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled. --- @@ -167,7 +171,7 @@ that match your granted scopes for that session. | **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. | | **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. | | **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. | -| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. | +| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. | --- @@ -194,7 +198,6 @@ making changes. | Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details | | Members | `trek://trips/{tripId}/members` | Owner and collaborators | | Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes | -| Files | `trek://trips/{tripId}/files` | Files attached to a trip (excludes trashed files) | | To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position | | Categories | `trek://categories` | Available place categories (for use when creating places) | | Bucket List | `trek://bucket-list` | Your personal travel bucket list | @@ -214,6 +217,10 @@ These resources are only available when the corresponding addon is enabled by an | Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) | | Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year | | Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year | +| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user | +| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips | +| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) | +| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey | --- @@ -226,7 +233,7 @@ trip in a single call. | Tool | Description | |--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, files, and poll/message counts. Use this as your context loader. | +| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. | ### Trips @@ -249,12 +256,14 @@ trip in a single call. | Tool | Description | |------------------|--------------------------------------------------------------------------------------------------| -| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. | -| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. | -| `update_place` | Update any field of an existing place including transport mode, timing, and price. | -| `delete_place` | Remove a place from a trip. | -| `list_categories`| List all available place categories with id, name, icon and color. | -| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. | +| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. | +| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. | +| `update_place` | Update any field of an existing place including transport mode, timing, and price. | +| `delete_place` | Remove a place from a trip. | +| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** | +| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. | +| `list_categories` | List all available place categories with id, name, icon and color. | +| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. | ### Day Planning @@ -279,15 +288,27 @@ trip in a single call. | `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). | | `delete_accommodation` | Delete an accommodation record from a trip. | +### Transport + +Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight. + +| Tool | Description | +|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. | +| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. | +| `delete_transport` | Delete a transport booking from a trip. | + ### Reservations -| Tool | Description | -|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. | -| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). | -| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. | -| `reorder_reservations` | Update the display order of reservations within a day. | -| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. | +For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types. + +| Tool | Description | +|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. | +| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). | +| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. | +| `reorder_reservations` | Update the display order of reservations (and transports) within a day. | +| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. | ### Budget @@ -370,7 +391,14 @@ trip in a single call. | `get_weather` | Get weather forecast for a location and date. | | `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. | -### Collab Notes +### Airports + +| Tool | Description | +|-------------------|-------------------------------------------------------------------------------------------------------------------| +| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. | +| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). | + +### Collab Notes _(Collab addon required)_ | Tool | Description | |----------------------|-------------------------------------------------------------------------------------------------| @@ -392,14 +420,14 @@ trip in a single call. | `delete_collab_message`| Delete a chat message (own messages only). | | `react_collab_message`| Toggle a reaction emoji on a chat message. | -### Bucket List +### Bucket List _(Atlas addon required)_ | Tool | Description | |---------------------------|--------------------------------------------------------------------------------------------| | `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. | | `delete_bucket_list_item` | Remove an item from your bucket list. | -### Atlas +### Atlas _(Atlas addon required)_ | Tool | Description | |--------------------------|---------------------------------------------------------------------------------| @@ -444,6 +472,33 @@ trip in a single call. | `list_holiday_countries` | List countries available for public holiday calendars. | | `list_holidays` | List public holidays for a country and year. | +### Journey _(Journey addon required)_ + +| Tool | Description | +|-----------------------------------|------------------------------------------------------------------------------------------------------------| +| `list_journeys` | List all journeys owned or contributed to by the current user. | +| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. | +| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. | +| `update_journey` | Update a journey's title, subtitle, or status. | +| `delete_journey` | Delete a journey. | +| `add_journey_trip` | Link an existing trip to a journey. | +| `remove_journey_trip` | Remove a trip from a journey. | +| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). | +| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. | +| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. | +| `delete_journey_entry` | Remove an entry from a journey. | +| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. | +| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). | +| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. | +| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. | +| `remove_journey_contributor` | Remove a contributor from a journey. | +| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). | +| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). | +| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. | +| `get_journey_share_link` | Get the current public share link for a journey. | +| `create_journey_share_link` | Create or update the public share link for a journey. | +| `delete_journey_share_link` | Revoke the public share link for a journey. | + --- ## Prompts diff --git a/client/src/api/oauthScopes.test.ts b/client/src/api/oauthScopes.test.ts index b16da606..9e5b538f 100644 --- a/client/src/api/oauthScopes.test.ts +++ b/client/src/api/oauthScopes.test.ts @@ -32,8 +32,8 @@ describe('SCOPE_GROUPS', () => { }) describe('ALL_SCOPES', () => { - it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => { - expect(ALL_SCOPES).toHaveLength(24) + it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => { + expect(ALL_SCOPES).toHaveLength(27) }) it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => { diff --git a/client/src/api/oauthScopes.ts b/client/src/api/oauthScopes.ts index 55cc3c09..22504e1a 100644 --- a/client/src/api/oauthScopes.ts +++ b/client/src/api/oauthScopes.ts @@ -38,6 +38,9 @@ export const SCOPE_GROUPS: Record = { 'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' }, 'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' }, 'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' }, + 'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' }, + 'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' }, + 'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' }, } export const ALL_SCOPES = Object.keys(SCOPE_GROUPS) diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 567de9fb..53497a76 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -135,7 +135,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel setToPick({ location: locationFromEndpoint(to) || undefined }) } } else { - setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' }) + setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' }) setFromPick({}) setToPick({}) } diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 643a03f0..9f4ec7be 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1992,6 +1992,7 @@ const ar: Record = { 'oauth.scope.group.vacay': 'الإجازة', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'الطقس', + 'oauth.scope.group.journey': 'مذكرة السفر', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر', @@ -2042,6 +2043,12 @@ const ar: Record = { 'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات', 'oauth.scope.weather:read.label': 'توقعات الطقس', 'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها', + 'oauth.scope.journey:read.label': 'عرض مذكرات السفر', + 'oauth.scope.journey:read.description': 'قراءة مذكرات السفر والمدخلات وقائمة المساهمين', + 'oauth.scope.journey:write.label': 'إدارة مذكرات السفر', + 'oauth.scope.journey:write.description': 'إنشاء مذكرات السفر وتحديثها وحذفها وإدخالاتها', + 'oauth.scope.journey:share.label': 'إدارة روابط مذكرات السفر', + 'oauth.scope.journey:share.description': 'إنشاء روابط مشاركة عامة لمذكرات السفر وتحديثها وإلغاؤها', // System notices 'system_notice.welcome_v1.title': 'مرحبًا بك في TREK', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index f60c7022..401ca860 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -2195,6 +2195,7 @@ const br: Record = { 'oauth.scope.group.vacay': 'Férias', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Clima', + 'oauth.scope.group.journey': 'Jornada', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Ver viagens e itinerários', @@ -2245,6 +2246,12 @@ const br: Record = { 'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas', 'oauth.scope.weather:read.label': 'Previsão do tempo', 'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem', + 'oauth.scope.journey:read.label': 'Ver jornadas', + 'oauth.scope.journey:read.description': 'Ler jornadas, entradas e lista de colaboradores', + 'oauth.scope.journey:write.label': 'Gerenciar jornadas', + 'oauth.scope.journey:write.description': 'Criar, atualizar e excluir jornadas e suas entradas', + 'oauth.scope.journey:share.label': 'Gerenciar links de jornadas', + 'oauth.scope.journey:share.description': 'Criar, atualizar e revogar links de compartilhamento públicos para jornadas', // System notices 'system_notice.welcome_v1.title': 'Bem-vindo ao TREK', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 9467988e..0b9887c5 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -2199,6 +2199,7 @@ const cs: Record = { 'oauth.scope.group.vacay': 'Dovolená', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Počasí', + 'oauth.scope.group.journey': 'Cestovní deník', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře', @@ -2249,6 +2250,12 @@ const cs: Record = { 'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice', 'oauth.scope.weather:read.label': 'Předpovědi počasí', 'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu', + 'oauth.scope.journey:read.label': 'Zobrazit cestovní deníky', + 'oauth.scope.journey:read.description': 'Číst cestovní deníky, záznamy a seznam přispěvatelů', + 'oauth.scope.journey:write.label': 'Spravovat cestovní deníky', + 'oauth.scope.journey:write.description': 'Vytvářet, aktualizovat a mazat cestovní deníky a jejich záznamy', + 'oauth.scope.journey:share.label': 'Spravovat odkazy na cestovní deníky', + 'oauth.scope.journey:share.description': 'Vytvářet, aktualizovat a rušit veřejné sdílené odkazy na cestovní deníky', // System notices 'system_notice.welcome_v1.title': 'Vítejte v TREK', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index d48a7636..d2fe26d7 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -2205,6 +2205,7 @@ const de: Record = { 'oauth.scope.group.vacay': 'Urlaub', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Wetter', + 'oauth.scope.group.journey': 'Journey', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Reisen und Reisepläne anzeigen', @@ -2255,6 +2256,12 @@ const de: Record = { 'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren', 'oauth.scope.weather:read.label': 'Wettervorhersagen', 'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen', + 'oauth.scope.journey:read.label': 'Journeys ansehen', + 'oauth.scope.journey:read.description': 'Journeys, Einträge und Mitarbeiterliste lesen', + 'oauth.scope.journey:write.label': 'Journeys verwalten', + 'oauth.scope.journey:write.description': 'Journeys und deren Einträge erstellen, bearbeiten und löschen', + 'oauth.scope.journey:share.label': 'Journey-Links verwalten', + 'oauth.scope.journey:share.description': 'Öffentliche Freigabelinks für Journeys erstellen, aktualisieren und widerrufen', // System notices 'system_notice.welcome_v1.title': 'Willkommen bei TREK', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b9aa3ec8..e1311a17 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -2242,6 +2242,7 @@ const en: Record = { 'oauth.scope.group.vacay': 'Vacation', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Weather', + 'oauth.scope.group.journey': 'Journey', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'View trips & itineraries', @@ -2292,6 +2293,12 @@ const en: Record = { 'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates', 'oauth.scope.weather:read.label': 'Weather forecasts', 'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates', + 'oauth.scope.journey:read.label': 'View journeys', + 'oauth.scope.journey:read.description': 'Read journeys, entries, and contributor list', + 'oauth.scope.journey:write.label': 'Manage journeys', + 'oauth.scope.journey:write.description': 'Create, update, and delete journeys and their entries', + 'oauth.scope.journey:share.label': 'Manage journey links', + 'oauth.scope.journey:share.description': 'Create, update, and revoke public share links for journeys', // System notices — 3.0.0 upgrade 'system_notice.v3_photos.title': 'Photos have moved in 3.0', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index d3bd7055..718de6ef 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -2201,6 +2201,7 @@ const es: Record = { 'oauth.scope.group.vacay': 'Vacaciones', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Clima', + 'oauth.scope.group.journey': 'Travesía', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Ver viajes e itinerarios', @@ -2251,6 +2252,12 @@ const es: Record = { 'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas', 'oauth.scope.weather:read.label': 'Previsiones meteorológicas', 'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje', + 'oauth.scope.journey:read.label': 'Ver travesías', + 'oauth.scope.journey:read.description': 'Leer travesías, entradas y lista de colaboradores', + 'oauth.scope.journey:write.label': 'Gestionar travesías', + 'oauth.scope.journey:write.description': 'Crear, actualizar y eliminar travesías y sus entradas', + 'oauth.scope.journey:share.label': 'Gestionar enlaces de travesías', + 'oauth.scope.journey:share.description': 'Crear, actualizar y revocar enlaces públicos de compartir para travesías', // System notices 'system_notice.welcome_v1.title': 'Bienvenido a TREK', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 60c64517..ea018c8a 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -2195,6 +2195,7 @@ const fr: Record = { 'oauth.scope.group.vacay': 'Congés', 'oauth.scope.group.geo': 'Géo', 'oauth.scope.group.weather': 'Météo', + 'oauth.scope.group.journey': 'Journal de voyage', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Voir les voyages et itinéraires', @@ -2245,6 +2246,12 @@ const fr: Record = { 'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées', 'oauth.scope.weather:read.label': 'Prévisions météo', 'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage', + 'oauth.scope.journey:read.label': 'Voir les journaux de voyage', + 'oauth.scope.journey:read.description': 'Lire les journaux de voyage, les entrées et la liste des contributeurs', + 'oauth.scope.journey:write.label': 'Gérer les journaux de voyage', + 'oauth.scope.journey:write.description': 'Créer, modifier et supprimer les journaux de voyage et leurs entrées', + 'oauth.scope.journey:share.label': 'Gérer les liens de journaux de voyage', + 'oauth.scope.journey:share.description': 'Créer, modifier et révoquer des liens de partage publics pour les journaux de voyage', // System notices 'system_notice.welcome_v1.title': 'Bienvenue sur TREK', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 6032603c..49d600b3 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -2196,6 +2196,7 @@ const hu: Record = { 'oauth.scope.group.vacay': 'Szabadság', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Időjárás', + 'oauth.scope.group.journey': 'Útinaplók', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Utazások és útvonalak megtekintése', @@ -2246,6 +2247,12 @@ const hu: Record = { 'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása', 'oauth.scope.weather:read.label': 'Időjárás-előrejelzések', 'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra', + 'oauth.scope.journey:read.label': 'Útinaplók megtekintése', + 'oauth.scope.journey:read.description': 'Útinaplók, bejegyzések és közreműködők listájának olvasása', + 'oauth.scope.journey:write.label': 'Útinaplók kezelése', + 'oauth.scope.journey:write.description': 'Útinaplók és bejegyzéseik létrehozása, frissítése és törlése', + 'oauth.scope.journey:share.label': 'Útinapló-linkek kezelése', + 'oauth.scope.journey:share.description': 'Nyilvános megosztási linkek létrehozása, frissítése és visszavonása útinaplókhoz', // System notices 'system_notice.welcome_v1.title': 'Üdvözöl a TREK', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index e959fc81..f1ecd8ff 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -2235,6 +2235,7 @@ const id: Record = { 'oauth.scope.group.vacay': 'Liburan', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Cuaca', + 'oauth.scope.group.journey': 'Journey', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Lihat perjalanan & itinerari', @@ -2285,6 +2286,12 @@ const id: Record = { 'oauth.scope.geo:read.description': 'Cari lokasi, selesaikan URL peta, dan geokode terbalik koordinat', 'oauth.scope.weather:read.label': 'Prakiraan cuaca', 'oauth.scope.weather:read.description': 'Ambil prakiraan cuaca untuk lokasi dan tanggal perjalanan', + 'oauth.scope.journey:read.label': 'Lihat Journey', + 'oauth.scope.journey:read.description': 'Baca Journey, entri, dan daftar kontributor', + 'oauth.scope.journey:write.label': 'Kelola Journey', + 'oauth.scope.journey:write.description': 'Buat, perbarui, dan hapus Journey beserta entrinya', + 'oauth.scope.journey:share.label': 'Kelola tautan Journey', + 'oauth.scope.journey:share.description': 'Buat, perbarui, dan cabut tautan berbagi publik untuk Journey', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index dd1bf217..4e24e6c0 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -2196,6 +2196,7 @@ const it: Record = { 'oauth.scope.group.vacay': 'Ferie', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Meteo', + 'oauth.scope.group.journey': 'Diario di viaggio', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Visualizza viaggi e itinerari', @@ -2246,6 +2247,12 @@ const it: Record = { 'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate', 'oauth.scope.weather:read.label': 'Previsioni meteo', 'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio', + 'oauth.scope.journey:read.label': 'Visualizza diari di viaggio', + 'oauth.scope.journey:read.description': 'Leggi diari di viaggio, voci e lista dei collaboratori', + 'oauth.scope.journey:write.label': 'Gestisci diari di viaggio', + 'oauth.scope.journey:write.description': 'Crea, aggiorna ed elimina diari di viaggio e le loro voci', + 'oauth.scope.journey:share.label': 'Gestisci link diari di viaggio', + 'oauth.scope.journey:share.description': 'Crea, aggiorna e revoca link di condivisione pubblici per i diari di viaggio', // System notices 'system_notice.welcome_v1.title': 'Benvenuto su TREK', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 95652487..4215a15b 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -2195,6 +2195,7 @@ const nl: Record = { 'oauth.scope.group.vacay': 'Vakantie', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Weer', + 'oauth.scope.group.journey': 'Reisverslag', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Reizen en reisplannen bekijken', @@ -2245,6 +2246,12 @@ const nl: Record = { 'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen', 'oauth.scope.weather:read.label': 'Weersverwachtingen', 'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums', + 'oauth.scope.journey:read.label': 'Reisverslagen bekijken', + 'oauth.scope.journey:read.description': 'Reisverslagen, vermeldingen en lijst van bijdragers lezen', + 'oauth.scope.journey:write.label': 'Reisverslagen beheren', + 'oauth.scope.journey:write.description': 'Reisverslagen en hun vermeldingen aanmaken, bijwerken en verwijderen', + 'oauth.scope.journey:share.label': 'Reisverslag-links beheren', + 'oauth.scope.journey:share.description': 'Publieke deellinks voor reisverslagen aanmaken, bijwerken en intrekken', // System notices 'system_notice.welcome_v1.title': 'Welkom bij TREK', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 340288c9..f4118357 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -2188,6 +2188,7 @@ const pl: Record = { 'oauth.scope.group.vacay': 'Urlop', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Pogoda', + 'oauth.scope.group.journey': 'Dziennik podróży', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Przeglądaj podróże i itineraria', @@ -2238,6 +2239,12 @@ const pl: Record = { 'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne', 'oauth.scope.weather:read.label': 'Prognozy pogody', 'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży', + 'oauth.scope.journey:read.label': 'Przeglądaj dzienniki podróży', + 'oauth.scope.journey:read.description': 'Odczytuj dzienniki podróży, wpisy i listę współautorów', + 'oauth.scope.journey:write.label': 'Zarządzaj dziennikami podróży', + 'oauth.scope.journey:write.description': 'Twórz, aktualizuj i usuwaj dzienniki podróży oraz ich wpisy', + 'oauth.scope.journey:share.label': 'Zarządzaj linkami dzienników podróży', + 'oauth.scope.journey:share.description': 'Twórz, aktualizuj i unieważniaj publiczne linki udostępniania dzienników podróży', // System notices 'system_notice.welcome_v1.title': 'Witaj w TREK', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 0cabc759..c87fc593 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -2195,6 +2195,7 @@ const ru: Record = { 'oauth.scope.group.vacay': 'Отпуск', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': 'Погода', + 'oauth.scope.group.journey': 'Путешествия', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': 'Просмотр поездок и маршрутов', @@ -2245,6 +2246,12 @@ const ru: Record = { 'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат', 'oauth.scope.weather:read.label': 'Прогнозы погоды', 'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки', + 'oauth.scope.journey:read.label': 'Просмотр путешествий', + 'oauth.scope.journey:read.description': 'Чтение путешествий, записей и списка участников', + 'oauth.scope.journey:write.label': 'Управление путешествиями', + 'oauth.scope.journey:write.description': 'Создание, обновление и удаление путешествий и их записей', + 'oauth.scope.journey:share.label': 'Управление ссылками на путешествия', + 'oauth.scope.journey:share.description': 'Создание, обновление и отзыв публичных ссылок для путешествий', // System notices 'system_notice.welcome_v1.title': 'Добро пожаловать в TREK', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index f2ff2e39..45069d6d 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -2195,6 +2195,7 @@ const zh: Record = { 'oauth.scope.group.vacay': '假期', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': '天气', + 'oauth.scope.group.journey': '旅程', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': '查看行程和行程计划', @@ -2245,6 +2246,12 @@ const zh: Record = { 'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标', 'oauth.scope.weather:read.label': '天气预报', 'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报', + 'oauth.scope.journey:read.label': '查看旅程', + 'oauth.scope.journey:read.description': '读取旅程、条目和贡献者列表', + 'oauth.scope.journey:write.label': '管理旅程', + 'oauth.scope.journey:write.description': '创建、更新和删除旅程及其条目', + 'oauth.scope.journey:share.label': '管理旅程链接', + 'oauth.scope.journey:share.description': '创建、更新和撤销旅程的公开分享链接', // System notices 'system_notice.welcome_v1.title': '欢迎使用 TREK', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 30eb8e42..8dc2177b 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -2196,6 +2196,7 @@ const zhTw: Record = { 'oauth.scope.group.vacay': '假期', 'oauth.scope.group.geo': 'Geo', 'oauth.scope.group.weather': '天氣', + 'oauth.scope.group.journey': '旅程', // OAuth scope labels & descriptions 'oauth.scope.trips:read.label': '檢視行程與旅遊計畫', @@ -2246,6 +2247,12 @@ const zhTw: Record = { 'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標', 'oauth.scope.weather:read.label': '天氣預報', 'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報', + 'oauth.scope.journey:read.label': '檢視旅程', + 'oauth.scope.journey:read.description': '讀取旅程、條目及貢獻者清單', + 'oauth.scope.journey:write.label': '管理旅程', + 'oauth.scope.journey:write.description': '建立、更新及刪除旅程及其條目', + 'oauth.scope.journey:share.label': '管理旅程連結', + 'oauth.scope.journey:share.description': '建立、更新及撤銷旅程的公開分享連結', // System notices 'system_notice.welcome_v1.title': '歡迎使用 TREK', diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index ce8bced5..710d1061 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -38,6 +38,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref - **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips. - **Atlas** — global travel journal: bucket list, visited countries and regions. - **Vacay** — vacation-day planner that tracks leave across team members and years. +- **Journey** — cross-trip travel narrative with dated entries, contributors, and share links. Requires the Journey addon. ## Key workflows @@ -75,6 +76,7 @@ The following features are optional and may not be available on every TREK insta - **Collab** — shared notes, polls, and chat messages for group trips. - **Atlas** — bucket list and visited-country/region tracking. - **Vacay** — team vacation-day planner with public holiday integration. +- **Journey** — cross-trip travel narrative with entries, contributors, and share links. ## Behavioral rules diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 87b393dc..443413cf 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -15,6 +15,7 @@ import { getNotifications } from '../services/inAppNotifications'; import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService'; import { isAddonEnabled } from '../services/adminService'; import { ADDON_IDS } from '../addons'; +import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService'; import { canRead, canReadTrips } from './scopes'; function parseId(value: string | string[]): number | null { @@ -381,4 +382,57 @@ export function registerResources(server: McpServer, userId: number, scopes: str } ); } + + // Journey resources (Journey addon) + if (isAddonEnabled(ADDON_IDS.JOURNEY) && canRead(scopes, 'journey')) { + server.registerResource( + 'journeys', + 'trek://journeys', + { description: 'All journeys owned or contributed to by the current user', mimeType: 'application/json' }, + async (uri) => { + const journeys = listJourneys(userId); + return jsonContent(uri.href, journeys); + } + ); + + server.registerResource( + 'journey-detail', + new ResourceTemplate('trek://journeys/{journeyId}', { list: undefined }), + { description: 'Single journey with entries, contributors, and trip links', mimeType: 'application/json' }, + async (uri, { journeyId }) => { + const id = parseId(journeyId); + if (id === null) return accessDenied(uri.href); + const journey = getJourneyFull(id, userId); + if (!journey) return accessDenied(uri.href); + return jsonContent(uri.href, journey); + } + ); + + server.registerResource( + 'journey-entries', + new ResourceTemplate('trek://journeys/{journeyId}/entries', { list: undefined }), + { description: 'All entries in a journey (date, text, mood, linked trip)', mimeType: 'application/json' }, + async (uri, { journeyId }) => { + const id = parseId(journeyId); + if (id === null) return accessDenied(uri.href); + const j = canAccessJourney(id, userId); + if (!j) return accessDenied(uri.href); + const entries = listEntries(id, userId); + return jsonContent(uri.href, entries); + } + ); + + server.registerResource( + 'journey-contributors', + new ResourceTemplate('trek://journeys/{journeyId}/contributors', { list: undefined }), + { description: 'Contributors (owners and collaborators) of a journey', mimeType: 'application/json' }, + async (uri, { journeyId }) => { + const id = parseId(journeyId); + if (id === null) return accessDenied(uri.href); + const j = getJourneyFull(id, userId); + if (!j) return accessDenied(uri.href); + return jsonContent(uri.href, (j as any).contributors ?? []); + } + ); + } } diff --git a/server/src/mcp/scopes.ts b/server/src/mcp/scopes.ts index a77fb433..c897a41d 100644 --- a/server/src/mcp/scopes.ts +++ b/server/src/mcp/scopes.ts @@ -27,6 +27,9 @@ export const SCOPES = { VACAY_WRITE: 'vacay:write', GEO_READ: 'geo:read', WEATHER_READ: 'weather:read', + JOURNEY_READ: 'journey:read', + JOURNEY_WRITE: 'journey:write', + JOURNEY_SHARE: 'journey:share', } as const; export type Scope = typeof SCOPES[keyof typeof SCOPES]; @@ -64,6 +67,9 @@ export const SCOPE_INFO: Record = { 'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' }, 'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' }, 'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' }, + 'journey:read': { label: 'View journeys', description: 'Read journeys, entries, and contributor list', group: 'Journey' }, + 'journey:write': { label: 'Manage journeys', description: 'Create, update, and delete journeys and their entries', group: 'Journey' }, + 'journey:share': { label: 'Manage journey links', description: 'Create, update, and revoke public share links for journeys', group: 'Journey' }, }; // --------------------------------------------------------------------------- @@ -101,6 +107,12 @@ export function canShareTrips(scopes: string[] | null): boolean { return scopes.includes('trips:share'); } +/** journey:share is a separate scope for managing public share links for journeys */ +export function canShareJourneys(scopes: string[] | null): boolean { + if (!scopes) return true; + return scopes.includes('journey:share'); +} + export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } { const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope)); return { valid: invalid.length === 0, invalid }; diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 14c9613a..7d12b8de 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -1,6 +1,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { registerTodoTools } from './tools/todos'; import { registerAssignmentTools } from './tools/assignments'; +import { registerJourneyTools } from './tools/journey'; import { registerReservationTools } from './tools/reservations'; import { registerTagTools } from './tools/tags'; import { registerMapsWeatherTools } from './tools/mapsWeather'; @@ -12,6 +13,7 @@ import { registerBudgetTools } from './tools/budget'; import { registerPackingTools } from './tools/packing'; import { registerCollabTools } from './tools/collab'; import { registerTripTools } from './tools/trips'; +import { registerTransportTools } from './tools/transports'; import { registerVacayTools } from './tools/vacay'; import { registerMcpPrompts } from './tools/prompts'; @@ -40,6 +42,10 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ registerCollabTools(server, userId, scopes); + registerTransportTools(server, userId, scopes); + + registerJourneyTools(server, userId, scopes); + registerVacayTools(server, userId, scopes); registerTodoTools(server, userId, scopes); diff --git a/server/src/mcp/tools/journey.ts b/server/src/mcp/tools/journey.ts new file mode 100644 index 00000000..90f2e40a --- /dev/null +++ b/server/src/mcp/tools/journey.ts @@ -0,0 +1,421 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { isDemoUser } from '../../services/authService'; +import { + addContributor, addTripToJourney, canAccessJourney, createEntry, createJourney, + deleteEntry, deleteJourney, getJourneyFull, getSuggestions, listEntries, + listJourneys, listUserTrips, removeContributor, removeTripFromJourney, + reorderEntries, updateContributorRole, updateEntry, updateJourney, + updateJourneyPreferences, +} from '../../services/journeyService'; +import { + createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink, +} from '../../services/journeyShareService'; +import { isAddonEnabled } from '../../services/adminService'; +import { ADDON_IDS } from '../../addons'; +import { + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + demoDenied, ok, +} from './_shared'; +import { canRead, canShareJourneys, canWrite } from '../scopes'; + +function notFound(msg: string) { + return { content: [{ type: 'text' as const, text: msg }], isError: true }; +} + +export function registerJourneyTools(server: McpServer, userId: number, scopes: string[] | null): void { + if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return; + + const R = canRead(scopes, 'journey'); + const W = canWrite(scopes, 'journey'); + const S = canShareJourneys(scopes); + + // --- READ TOOLS --- + + if (R) server.registerTool( + 'list_journeys', + { + description: 'List all journeys owned or contributed to by the current user.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const journeys = listJourneys(userId); + return ok({ journeys }); + } + ); + + if (R) server.registerTool( + 'get_journey', + { + description: 'Get a full journey including entries, contributors, and linked trips.', + inputSchema: { + journeyId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ journeyId }) => { + const journey = getJourneyFull(journeyId, userId); + if (!journey) return notFound('Journey not found or access denied.'); + return ok({ journey }); + } + ); + + if (R) server.registerTool( + 'list_journey_entries', + { + description: 'List all entries in a journey.', + inputSchema: { + journeyId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ journeyId }) => { + if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.'); + const entries = listEntries(journeyId, userId); + return ok({ entries }); + } + ); + + if (R) server.registerTool( + 'list_journey_contributors', + { + description: 'List all contributors (owner and collaborators) of a journey.', + inputSchema: { + journeyId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ journeyId }) => { + const journey = getJourneyFull(journeyId, userId); + if (!journey) return notFound('Journey not found or access denied.'); + return ok({ contributors: (journey as any).contributors ?? [] }); + } + ); + + if (R) server.registerTool( + 'get_journey_suggestions', + { + description: 'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const trips = getSuggestions(userId); + return ok({ trips }); + } + ); + + if (R) server.registerTool( + 'list_journey_available_trips', + { + description: 'List all trips available to link to a journey.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const trips = listUserTrips(userId); + return ok({ trips }); + } + ); + + // --- WRITE TOOLS --- + + if (W) server.registerTool( + 'create_journey', + { + description: 'Create a new journey, optionally linking existing trips.', + inputSchema: { + title: z.string().min(1).max(200), + subtitle: z.string().max(300).optional(), + trip_ids: z.array(z.number().int().positive()).optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ title, subtitle, trip_ids }) => { + if (isDemoUser(userId)) return demoDenied(); + const journey = createJourney(userId, { title, subtitle, trip_ids }); + return ok({ journey }); + } + ); + + if (W) server.registerTool( + 'update_journey', + { + description: 'Update an existing journey\'s title, subtitle, cover, or status. Owner only.', + inputSchema: { + journeyId: z.number().int().positive(), + title: z.string().min(1).max(200).optional(), + subtitle: z.string().max(300).optional(), + status: z.enum(['draft', 'active', 'completed', 'archived']).optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ journeyId, title, subtitle, status }) => { + if (isDemoUser(userId)) return demoDenied(); + const journey = updateJourney(journeyId, userId, { title, subtitle, status }); + if (!journey) return notFound('Journey not found or access denied.'); + return ok({ journey }); + } + ); + + if (W) server.registerTool( + 'delete_journey', + { + description: 'Delete a journey. Owner only — this cannot be undone.', + inputSchema: { + journeyId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ journeyId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = deleteJourney(journeyId, userId); + if (!success) return notFound('Journey not found or access denied.'); + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'add_journey_trip', + { + description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.', + inputSchema: { + journeyId: z.number().int().positive(), + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ journeyId, tripId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.'); + const success = addTripToJourney(journeyId, tripId, userId); + return ok({ success }); + } + ); + + if (W) server.registerTool( + 'remove_journey_trip', + { + description: 'Unlink a trip from a journey. Owner only.', + inputSchema: { + journeyId: z.number().int().positive(), + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ journeyId, tripId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = removeTripFromJourney(journeyId, tripId, userId); + if (!success) return notFound('Journey not found or access denied.'); + return ok({ success }); + } + ); + + if (W) server.registerTool( + 'create_journey_entry', + { + description: 'Create a new entry in a journey.', + inputSchema: { + journeyId: z.number().int().positive(), + entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Entry date (YYYY-MM-DD)'), + title: z.string().max(300).optional(), + story: z.string().optional(), + entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'), + location_name: z.string().optional(), + mood: z.string().optional(), + sort_order: z.number().int().min(0).optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ journeyId, entry_date, title, story, entry_time, location_name, mood, sort_order }) => { + if (isDemoUser(userId)) return demoDenied(); + const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order }); + if (!entry) return notFound('Journey not found or access denied.'); + return ok({ entry }); + } + ); + + if (W) server.registerTool( + 'update_journey_entry', + { + description: 'Update an existing journey entry.', + inputSchema: { + entryId: z.number().int().positive(), + title: z.string().max(300).optional(), + story: z.string().optional(), + entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + entry_time: z.string().optional(), + mood: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ entryId, title, story, entry_date, entry_time, mood }) => { + if (isDemoUser(userId)) return demoDenied(); + const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined); + if (!entry) return notFound('Entry not found or access denied.'); + return ok({ entry }); + } + ); + + if (W) server.registerTool( + 'delete_journey_entry', + { + description: 'Delete a journey entry.', + inputSchema: { + entryId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ entryId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = deleteEntry(entryId, userId, undefined); + if (!success) return notFound('Entry not found or access denied.'); + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'reorder_journey_entries', + { + description: 'Reorder entries within a journey by providing the desired order of entry IDs.', + inputSchema: { + journeyId: z.number().int().positive(), + orderedIds: z.array(z.number().int().positive()), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ journeyId, orderedIds }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = reorderEntries(journeyId, userId, orderedIds, undefined); + if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.'); + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'add_journey_contributor', + { + description: 'Add a contributor to a journey. Owner only.', + inputSchema: { + journeyId: z.number().int().positive(), + targetUserId: z.number().int().positive(), + role: z.enum(['editor', 'viewer']), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ journeyId, targetUserId, role }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = addContributor(journeyId, userId, targetUserId, role); + if (!success) return notFound('Journey not found or access denied.'); + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'update_journey_contributor_role', + { + description: 'Update the role of a journey contributor. Owner only.', + inputSchema: { + journeyId: z.number().int().positive(), + targetUserId: z.number().int().positive(), + role: z.enum(['editor', 'viewer']), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ journeyId, targetUserId, role }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = updateContributorRole(journeyId, userId, targetUserId, role); + if (!success) return notFound('Journey not found or access denied.'); + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'remove_journey_contributor', + { + description: 'Remove a contributor from a journey. Owner only.', + inputSchema: { + journeyId: z.number().int().positive(), + targetUserId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ journeyId, targetUserId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = removeContributor(journeyId, userId, targetUserId); + if (!success) return notFound('Journey not found or access denied.'); + return ok({ success: true }); + } + ); + + if (W) server.registerTool( + 'update_journey_preferences', + { + description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).', + inputSchema: { + journeyId: z.number().int().positive(), + hide_skeletons: z.boolean().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ journeyId, hide_skeletons }) => { + if (isDemoUser(userId)) return demoDenied(); + const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons }); + if (!result) return notFound('Journey not found or access denied.'); + return ok({ success: true }); + } + ); + + // --- SHARE TOOLS --- + + if (S) server.registerTool( + 'get_journey_share_link', + { + description: 'Get the current public share link for a journey. Returns null if none exists.', + inputSchema: { + journeyId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ journeyId }) => { + const shareLink = getJourneyShareLink(journeyId); + return ok({ shareLink }); + } + ); + + if (S) server.registerTool( + 'create_journey_share_link', + { + description: 'Create or update the public share link for a journey. Owner only.', + inputSchema: { + journeyId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ journeyId }) => { + if (isDemoUser(userId)) return demoDenied(); + const shareLink = createOrUpdateJourneyShareLink(journeyId, userId, {}); + if (!shareLink) return notFound('Journey not found or access denied.'); + return ok({ shareLink }); + } + ); + + if (S) server.registerTool( + 'delete_journey_share_link', + { + description: 'Revoke the public share link for a journey. Owner only.', + inputSchema: { + journeyId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ journeyId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = deleteJourneyShareLink(journeyId, userId); + if (!success) return notFound('Journey not found or access denied.'); + return ok({ success: true }); + } + ); +} diff --git a/server/src/mcp/tools/mapsWeather.ts b/server/src/mcp/tools/mapsWeather.ts index 0929d4b6..c2dad691 100644 --- a/server/src/mcp/tools/mapsWeather.ts +++ b/server/src/mcp/tools/mapsWeather.ts @@ -1,5 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; +import { findByIata, searchAirports } from '../../services/airportService'; import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService'; import { getWeather, getDetailedWeather } from '../../services/weatherService'; import { @@ -110,4 +111,38 @@ export function registerMapsWeatherTools(server: McpServer, userId: number, scop } } ); + + // --- AIRPORTS --- + + if (canGeo) server.registerTool( + 'search_airports', + { + description: 'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.', + inputSchema: { + query: z.string().min(1).max(200).describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'), + limit: z.number().int().min(1).max(50).optional().default(10), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ query, limit }) => { + const airports = searchAirports(query, limit ?? 10); + return ok({ airports }); + } + ); + + if (canGeo) server.registerTool( + 'get_airport', + { + description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.', + inputSchema: { + iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ iata }) => { + const airport = findByIata(iata); + if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true }; + return ok({ airport }); + } + ); } diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index e54c9b47..7b4c3607 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -2,7 +2,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { canAccessTrip } from '../../db/database'; import { isDemoUser } from '../../services/authService'; -import { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; +import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; +import { onPlaceDeleted } from '../../services/journeyService'; import { listCategories } from '../../services/categoryService'; import { searchPlaces } from '../../services/mapsService'; import { @@ -159,4 +160,57 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st } } ); + + if (W) server.registerTool( + 'import_places_from_url', + { + description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.', + inputSchema: { + tripId: z.number().int().positive(), + url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'), + source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, url, source }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + + const result = source === 'google-list' + ? await importGoogleList(String(tripId), url) + : await importNaverList(String(tripId), url); + + if ('error' in result) { + return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + } + + for (const place of result.places) { + safeBroadcast(tripId, 'place:created', { place }); + } + return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); + } + ); + + if (W) server.registerTool( + 'bulk_delete_places', + { + description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.', + inputSchema: { + tripId: z.number().int().positive(), + placeIds: z.array(z.number().int().positive()).min(1).max(200), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, placeIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + + const deleted = deletePlacesMany(String(tripId), placeIds); + for (const id of deleted) { + safeBroadcast(tripId, 'place:deleted', { placeId: id }); + try { onPlaceDeleted(id); } catch {} + } + return ok({ deleted, count: deleted.length }); + } + ); } diff --git a/server/src/mcp/tools/reservations.ts b/server/src/mcp/tools/reservations.ts index 99d733ac..1a2acef5 100644 --- a/server/src/mcp/tools/reservations.ts +++ b/server/src/mcp/tools/reservations.ts @@ -22,11 +22,11 @@ export function registerReservationTools(server: McpServer, userId: number, scop server.registerTool( 'create_reservation', { - description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.', + description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.', inputSchema: { tripId: z.number().int().positive(), title: z.string().min(1).max(200), - type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'), + type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), @@ -78,12 +78,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop server.registerTool( 'update_reservation', { - description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.', + description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/other → use assignment_id to link to a day assignment.', inputSchema: { tripId: z.number().int().positive(), reservationId: z.number().int().positive(), title: z.string().min(1).max(200).optional(), - type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'), + type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts new file mode 100644 index 00000000..c6e44812 --- /dev/null +++ b/server/src/mcp/tools/transports.ts @@ -0,0 +1,158 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + createReservation, deleteReservation, getReservation, updateReservation, +} from '../../services/reservationService'; +import { getDay } from '../../services/dayService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, +} from './_shared'; +import { canWrite } from '../scopes'; + +const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const; + +const endpointSchema = z.array(z.object({ + role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'), + sequence: z.number().int().min(0).describe('Order within the route (0-based)'), + name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'), + code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'), + lat: z.number().optional(), + lng: z.number().optional(), + timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'), + local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'), + local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'), +})).optional(); + +export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void { + if (!canWrite(scopes, 'reservations')) return; + + server.registerTool( + 'create_transport', + { + description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.', + inputSchema: { + tripId: z.number().int().positive(), + type: z.enum(['flight', 'train', 'car', 'cruise']), + title: z.string().min(1).max(200), + status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'), + start_day_id: z.number().int().positive().optional().describe('Departure day'), + end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'), + reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), + reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), + confirmation_number: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), + endpoints: endpointSchema, + needs_review: z.boolean().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + + if (start_day_id && !getDay(start_day_id, tripId)) + return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true }; + if (end_day_id && !getDay(end_day_id, tripId)) + return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; + + const { reservation } = createReservation(tripId, { + title, + type, + reservation_time, + reservation_end_time, + location: undefined, + confirmation_number, + notes, + day_id: start_day_id, + end_day_id: end_day_id ?? start_day_id, + status: status ?? 'pending', + metadata, + endpoints, + needs_review, + }); + safeBroadcast(tripId, 'reservation:created', { reservation }); + return ok({ reservation }); + } + ); + + server.registerTool( + 'update_transport', + { + description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.', + inputSchema: { + tripId: z.number().int().positive(), + reservationId: z.number().int().positive(), + type: z.enum(['flight', 'train', 'car', 'cruise']).optional(), + title: z.string().min(1).max(200).optional(), + status: z.enum(['pending', 'confirmed', 'cancelled']).optional(), + start_day_id: z.number().int().positive().optional().describe('Departure day'), + end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'), + reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), + reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), + confirmation_number: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), + endpoints: endpointSchema, + needs_review: z.boolean().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + + const existing = getReservation(reservationId, tripId); + if (!existing) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true }; + + const resolvedType = type ?? existing.type; + if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType)) + return { content: [{ type: 'text' as const, text: 'Reservation is not a transport type. Use update_reservation instead.' }], isError: true }; + + if (start_day_id && !getDay(start_day_id, tripId)) + return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true }; + if (end_day_id && !getDay(end_day_id, tripId)) + return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; + + const { reservation } = updateReservation(reservationId, tripId, { + title, + type, + reservation_time, + reservation_end_time, + confirmation_number, + notes, + day_id: start_day_id, + end_day_id, + status, + metadata, + endpoints, + needs_review, + }, existing); + safeBroadcast(tripId, 'reservation:updated', { reservation }); + return ok({ reservation }); + } + ); + + server.registerTool( + 'delete_transport', + { + description: 'Delete a transport booking from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + reservationId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, reservationId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const { deleted } = deleteReservation(reservationId, tripId); + if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true }; + safeBroadcast(tripId, 'reservation:deleted', { reservationId }); + return ok({ success: true }); + } + ); +} diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index 30a813bb..933c34a3 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -64,17 +64,17 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise) // --------------------------------------------------------------------------- describe('Tool: create_reservation', () => { - it('creates a basic flight reservation', async () => { + it('creates a basic reservation', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'create_reservation', - arguments: { tripId: trip.id, title: 'Flight to Rome', type: 'flight' }, + arguments: { tripId: trip.id, title: 'Eiffel Tower Tour', type: 'tour' }, }); const data = parseToolResult(result) as any; - expect(data.reservation.title).toBe('Flight to Rome'); - expect(data.reservation.type).toBe('flight'); + expect(data.reservation.title).toBe('Eiffel Tower Tour'); + expect(data.reservation.type).toBe('tour'); expect(data.reservation.status).toBe('pending'); }); });