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 ?? []); } ); } }