mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
dd90c6d424
Root cause: claude.ai's MCP connector (spec 2025-06-18) requires the resource server to publish Protected Resource Metadata and return WWW-Authenticate on 401s to bind the /mcp endpoint to its AS. Without these, it silently shows no tools after OAuth. - Add /.well-known/oauth-protected-resource (RFC 9728) with addon gating - Emit WWW-Authenticate: Bearer resource_metadata=... on 401/auth-failure 403s - Open CORS (origin: *) on both .well-known/* endpoints per RFC 8414/9728 - Accept resource parameter at authorize + token endpoints (RFC 8707) - Store audience on oauth_tokens; validate on every MCP request - Refresh tokens inherit audience; add resource_parameter_supported to AS metadata - DB migration: ADD COLUMN audience TEXT to oauth_tokens - Gate collab MCP tools/resources by chat/notes/polls sub-features individually - Invalidate MCP sessions when collab sub-features are toggled in admin - Update test mocks and MCP.md
443 lines
18 KiB
TypeScript
443 lines
18 KiB
TypeScript
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 ?? []);
|
|
}
|
|
);
|
|
}
|
|
}
|