Spaces:
Sleeping
Sleeping
| import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; | |
| import { canAccessTrip } from '../db/database'; | |
| import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService'; | |
| import { listDays, listAccommodations } from '../services/dayService'; | |
| import { listPlaces } from '../services/placeService'; | |
| import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService'; | |
| import { listItems as listPackingItems, listBags } from '../services/packingService'; | |
| import { listReservations } from '../services/reservationService'; | |
| import { listNotes as listDayNotes } from '../services/dayNoteService'; | |
| import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService'; | |
| import { listItems as listTodoItems } from '../services/todoService'; | |
| import { listCategories } from '../services/categoryService'; | |
| import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService'; | |
| import { getNotifications } from '../services/inAppNotifications'; | |
| import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService'; | |
| import { isAddonEnabled, getCollabFeatures } 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 { | |
| const n = Number(Array.isArray(value) ? value[0] : value); | |
| return Number.isInteger(n) && n > 0 ? n : null; | |
| } | |
| function accessDenied(uri: string) { | |
| return { | |
| contents: [{ | |
| uri, | |
| mimeType: 'application/json', | |
| text: JSON.stringify({ error: 'Trip not found or access denied' }), | |
| }], | |
| }; | |
| } | |
| function scopeDenied(uri: string) { | |
| return { | |
| contents: [{ | |
| uri, | |
| mimeType: 'application/json', | |
| text: JSON.stringify({ error: 'Insufficient OAuth scope to access this resource' }), | |
| }], | |
| }; | |
| } | |
| function jsonContent(uri: string, data: unknown) { | |
| return { | |
| contents: [{ | |
| uri, | |
| mimeType: 'application/json', | |
| text: JSON.stringify(data, null, 2), | |
| }], | |
| }; | |
| } | |
| export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void { | |
| // List all accessible trips | |
| if (canReadTrips(scopes)) server.registerResource( | |
| 'trips', | |
| 'trek://trips', | |
| { description: 'All trips the user owns or is a member of', mimeType: 'application/json' }, | |
| async (uri) => { | |
| const trips = listTrips(userId, 0); | |
| return jsonContent(uri.href, trips); | |
| } | |
| ); | |
| // Single trip detail | |
| if (canReadTrips(scopes)) server.registerResource( | |
| 'trip', | |
| new ResourceTemplate('trek://trips/{tripId}', { list: undefined }), | |
| { description: 'A single trip with metadata and member count', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const trip = getTrip(id, userId); | |
| return jsonContent(uri.href, trip); | |
| } | |
| ); | |
| // Days with assigned places | |
| if (canReadTrips(scopes)) server.registerResource( | |
| 'trip-days', | |
| new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }), | |
| { description: 'Days of a trip with their assigned places', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const { days } = listDays(id); | |
| return jsonContent(uri.href, days); | |
| } | |
| ); | |
| // Places in a trip | |
| if (canRead(scopes, 'places')) server.registerResource( | |
| 'trip-places', | |
| new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }), | |
| { description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null; | |
| const places = listPlaces(String(id), { assignment: assignment ?? undefined }); | |
| return jsonContent(uri.href, places); | |
| } | |
| ); | |
| // Budget items | |
| if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource( | |
| 'trip-budget', | |
| new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }), | |
| { description: 'Budget and expense items for a trip', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const items = listBudgetItems(id); | |
| return jsonContent(uri.href, items); | |
| } | |
| ); | |
| // Packing checklist | |
| if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource( | |
| 'trip-packing', | |
| new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }), | |
| { description: 'Packing checklist for a trip', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const items = listPackingItems(id); | |
| return jsonContent(uri.href, items); | |
| } | |
| ); | |
| // Reservations (flights, hotels, restaurants) | |
| if (canRead(scopes, 'reservations')) server.registerResource( | |
| 'trip-reservations', | |
| new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }), | |
| { description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const reservations = listReservations(id); | |
| return jsonContent(uri.href, reservations); | |
| } | |
| ); | |
| // Day notes | |
| if (canReadTrips(scopes)) server.registerResource( | |
| 'day-notes', | |
| new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }), | |
| { description: 'Notes for a specific day in a trip', mimeType: 'application/json' }, | |
| async (uri, { tripId, dayId }) => { | |
| const tId = parseId(tripId); | |
| const dId = parseId(dayId); | |
| if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href); | |
| const notes = listDayNotes(dId, tId); | |
| return jsonContent(uri.href, notes); | |
| } | |
| ); | |
| // Accommodations (hotels, rentals) per trip | |
| if (canReadTrips(scopes)) server.registerResource( | |
| 'trip-accommodations', | |
| new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }), | |
| { description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const accommodations = listAccommodations(id); | |
| return jsonContent(uri.href, accommodations); | |
| } | |
| ); | |
| // Trip members (owner + collaborators) | |
| if (canReadTrips(scopes)) server.registerResource( | |
| 'trip-members', | |
| new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }), | |
| { description: 'Owner and collaborators of a trip', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const ownerRow = getTripOwner(id); | |
| if (!ownerRow) return accessDenied(uri.href); | |
| const { owner, members } = listMembers(id, ownerRow.user_id); | |
| return jsonContent(uri.href, { owner, members }); | |
| } | |
| ); | |
| // Collab notes for a trip | |
| const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null; | |
| if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource( | |
| 'trip-collab-notes', | |
| new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }), | |
| { description: 'Shared collaborative notes for a trip', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const notes = listCollabNotes(id); | |
| return jsonContent(uri.href, notes); | |
| } | |
| ); | |
| // Trip to-do list | |
| if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'todos')) server.registerResource( | |
| 'trip-todos', | |
| new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }), | |
| { description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const items = listTodoItems(id); | |
| return jsonContent(uri.href, items); | |
| } | |
| ); | |
| // All place categories (global, no trip filter) — safe for any authenticated session | |
| server.registerResource( | |
| 'categories', | |
| 'trek://categories', | |
| { description: 'All available place categories (id, name, color, icon) for use when creating places', mimeType: 'application/json' }, | |
| async (uri) => { | |
| const categories = listCategories(); | |
| return jsonContent(uri.href, categories); | |
| } | |
| ); | |
| // User's bucket list | |
| if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource( | |
| 'bucket-list', | |
| 'trek://bucket-list', | |
| { description: 'Your personal travel bucket list', mimeType: 'application/json' }, | |
| async (uri) => { | |
| const items = listBucketList(userId); | |
| return jsonContent(uri.href, items); | |
| } | |
| ); | |
| // User's visited countries | |
| if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource( | |
| 'visited-countries', | |
| 'trek://visited-countries', | |
| { description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' }, | |
| async (uri) => { | |
| const countries = listVisitedCountries(userId); | |
| return jsonContent(uri.href, countries); | |
| } | |
| ); | |
| // Budget per-person summary | |
| if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource( | |
| 'trip-budget-per-person', | |
| new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }), | |
| { description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const summary = getPerPersonSummary(id); | |
| return jsonContent(uri.href, summary); | |
| } | |
| ); | |
| // Budget settlement | |
| if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource( | |
| 'trip-budget-settlement', | |
| new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }), | |
| { description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const settlement = calculateSettlement(id); | |
| return jsonContent(uri.href, settlement); | |
| } | |
| ); | |
| // Packing bags | |
| if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource( | |
| 'trip-packing-bags', | |
| new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }), | |
| { description: 'All packing bags for a trip with their members', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const bags = listBags(id); | |
| return jsonContent(uri.href, bags); | |
| } | |
| ); | |
| // In-app notifications | |
| if (canRead(scopes, 'notifications')) server.registerResource( | |
| 'notifications-in-app', | |
| 'trek://notifications/in-app', | |
| { description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' }, | |
| async (uri) => { | |
| const result = getNotifications(userId, { limit: 50 }); | |
| return jsonContent(uri.href, result); | |
| } | |
| ); | |
| // Atlas stats and regions (addon-gated) | |
| if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) { | |
| server.registerResource( | |
| 'atlas-stats', | |
| 'trek://atlas/stats', | |
| { description: "User's atlas statistics — visited country counts and breakdown", mimeType: 'application/json' }, | |
| async (uri) => { | |
| const stats = await getAtlasStats(userId); | |
| return jsonContent(uri.href, stats); | |
| } | |
| ); | |
| server.registerResource( | |
| 'atlas-regions', | |
| 'trek://atlas/regions', | |
| { description: 'List of manually visited regions for the current user', mimeType: 'application/json' }, | |
| async (uri) => { | |
| const regions = listManuallyVisitedRegions(userId); | |
| return jsonContent(uri.href, regions); | |
| } | |
| ); | |
| } | |
| // Collab polls (addon + sub-feature gated) | |
| if (collabFeatures?.polls && canRead(scopes, 'collab')) { | |
| server.registerResource( | |
| 'trip-collab-polls', | |
| new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }), | |
| { description: 'All polls for a trip with vote counts per option', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const polls = listPolls(id); | |
| return jsonContent(uri.href, polls); | |
| } | |
| ); | |
| } | |
| // Collab messages (addon + sub-feature gated) | |
| if (collabFeatures?.chat && canRead(scopes, 'collab')) { | |
| server.registerResource( | |
| 'trip-collab-messages', | |
| new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }), | |
| { description: 'Most recent 100 chat messages for a trip', mimeType: 'application/json' }, | |
| async (uri, { tripId }) => { | |
| const id = parseId(tripId); | |
| if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); | |
| const messages = listMessages(id); | |
| return jsonContent(uri.href, messages); | |
| } | |
| ); | |
| } | |
| // Vacay resources (addon-gated) | |
| if (isAddonEnabled(ADDON_IDS.VACAY) && canRead(scopes, 'vacay')) { | |
| server.registerResource( | |
| 'vacay-plan', | |
| 'trek://vacay/plan', | |
| { description: "Full snapshot of the user's active vacation plan (members, years, settings)", mimeType: 'application/json' }, | |
| async (uri) => { | |
| const plan = getPlanData(userId); | |
| return jsonContent(uri.href, plan); | |
| } | |
| ); | |
| server.registerResource( | |
| 'vacay-entries', | |
| new ResourceTemplate('trek://vacay/entries/{year}', { list: undefined }), | |
| { description: 'All vacation entries for the active plan and a specific year', mimeType: 'application/json' }, | |
| async (uri, { year }) => { | |
| const planId = getActivePlanId(userId); | |
| const entries = getVacayEntries(planId, Array.isArray(year) ? year[0] : year); | |
| return jsonContent(uri.href, entries); | |
| } | |
| ); | |
| server.registerResource( | |
| 'vacay-holidays', | |
| new ResourceTemplate('trek://vacay/holidays/{year}', { list: undefined }), | |
| { description: "Cached public holidays for the plan's configured region and year", mimeType: 'application/json' }, | |
| async (uri, { year }) => { | |
| const plan = getActivePlan(userId); | |
| if (!plan.holidays_enabled || !plan.holidays_region) return jsonContent(uri.href, []); | |
| const yearStr = Array.isArray(year) ? year[0] : year; | |
| const result = await getHolidays(yearStr, plan.holidays_region); | |
| return jsonContent(uri.href, result.data ?? []); | |
| } | |
| ); | |
| } | |
| // 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 ?? []); | |
| } | |
| ); | |
| } | |
| } | |