mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
feat(mcp): align MCP surface with current app state
- Add Journey addon tools (list, get, entries, contributors, suggestions, available trips, create/update/delete journey and entries, reorder, contributors CRUD, preferences, share link management) - Add Journey resources (trek://journeys and sub-resources) - Split transport (flight/train/car/cruise) into dedicated tools with endpoints[] and needs_review support; narrow reservation types to non-transport only - Add airport lookup tools (search_airports, get_airport) under geo:read - Add import_places_from_url and bulk_delete_places to places tools - Add journey:read/write/share OAuth scopes (27 total) with translations across all 15 locales - Default end_day to start_day when creating a transport (MCP + UI) - Fix MCP.md drift: addon gates, removed files resource, corrected get_trip_summary description, todos under Packing addon
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -38,6 +38,9 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'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)
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
|
||||
@@ -1992,6 +1992,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2199,6 +2199,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2205,6 +2205,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2242,6 +2242,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2201,6 +2201,7 @@ const es: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const fr: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2196,6 +2196,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2235,6 +2235,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2196,6 +2196,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const nl: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2188,6 +2188,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const ru: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const zh: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -2196,6 +2196,7 @@ const zhTw: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Scope, ScopeInfo> = {
|
||||
'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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -64,17 +64,17 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user