mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
535c06bb3f
- Split `media:read` into `geo:read` and `weather:read` scopes - Add dedicated `atlas:read/write` scopes (previously under `places`) - Add dedicated `todos:read/write` scopes (previously under `collab`) - Rate limiting now keyed by userId+clientId instead of userId alone - Bind MCP sessions to the OAuth client that created them - Log MCP tool calls to audit log with clientId - Invalidate all MCP sessions on addon state change - Reduce session sweep interval from 10min to 1min - Update all translations with new scope labels
383 lines
16 KiB
TypeScript
383 lines
16 KiB
TypeScript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
|
import { z } from 'zod';
|
|
import { canAccessTrip } from '../../db/database';
|
|
import { isDemoUser } from '../../services/authService';
|
|
import {
|
|
listTrips, createTrip, updateTrip, deleteTrip, getTripSummary,
|
|
isOwner, verifyTripAccess,
|
|
listMembers as listTripMembers, getTripOwner, addMember as addTripMember,
|
|
removeMember as removeTripMember,
|
|
copyTripById, exportICS, NotFoundError, ValidationError,
|
|
} from '../../services/tripService';
|
|
import {
|
|
createOrUpdateShareLink, getShareLink, deleteShareLink,
|
|
} from '../../services/shareService';
|
|
import { isAddonEnabled } from '../../services/adminService';
|
|
import { ADDON_IDS } from '../../addons';
|
|
import { countMessages, listPolls } from '../../services/collabService';
|
|
import {
|
|
listItems as listTodoItems,
|
|
} from '../../services/todoService';
|
|
import {
|
|
safeBroadcast, MAX_MCP_TRIP_DAYS,
|
|
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
|
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
demoDenied, noAccess, ok,
|
|
} from './_shared';
|
|
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
|
|
|
|
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void {
|
|
const R = canReadTrips(scopes);
|
|
const W = canWrite(scopes, 'trips');
|
|
const D = canDeleteTrips(scopes);
|
|
const S = canShareTrips(scopes);
|
|
|
|
// --- TRIPS ---
|
|
|
|
if (W) server.registerTool(
|
|
'create_trip',
|
|
{
|
|
description: 'Create a new trip. Returns the created trip with its generated days.',
|
|
inputSchema: {
|
|
title: z.string().min(1).max(200).describe('Trip title'),
|
|
description: z.string().max(2000).optional().describe('Trip description'),
|
|
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
|
|
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
|
|
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ title, description, start_date, end_date, currency }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (start_date) {
|
|
const d = new Date(start_date + 'T00:00:00Z');
|
|
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
|
|
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
|
|
}
|
|
if (end_date) {
|
|
const d = new Date(end_date + 'T00:00:00Z');
|
|
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
|
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
|
}
|
|
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
|
|
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
|
|
}
|
|
const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
|
|
return ok({ trip });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'update_trip',
|
|
{
|
|
description: 'Update an existing trip\'s details.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
title: z.string().min(1).max(200).optional(),
|
|
description: z.string().max(2000).optional(),
|
|
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
currency: z.string().length(3).optional(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (start_date) {
|
|
const d = new Date(start_date + 'T00:00:00Z');
|
|
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
|
|
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
|
|
}
|
|
if (end_date) {
|
|
const d = new Date(end_date + 'T00:00:00Z');
|
|
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
|
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
|
}
|
|
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
|
|
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
|
return ok({ trip: updatedTrip });
|
|
}
|
|
);
|
|
|
|
if (D) server.registerTool(
|
|
'delete_trip',
|
|
{
|
|
description: 'Delete a trip. Only the trip owner can delete it.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_DELETE,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!isOwner(tripId, userId)) return noAccess();
|
|
deleteTrip(tripId, userId, 'user');
|
|
return ok({ success: true, tripId });
|
|
}
|
|
);
|
|
|
|
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
|
|
// they are navigation tools that any MCP client needs to discover trip IDs.
|
|
server.registerTool(
|
|
'list_trips',
|
|
{
|
|
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
|
|
inputSchema: {
|
|
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
},
|
|
async ({ include_archived }) => {
|
|
const notice = getDeprecationNotice();
|
|
const trips = listTrips(userId, include_archived ? null : 0);
|
|
if (notice) return {
|
|
isError: true as const,
|
|
content: [
|
|
{ type: 'text' as const, text: notice },
|
|
{ type: 'text' as const, text: JSON.stringify({ trips }, null, 2) },
|
|
],
|
|
};
|
|
return ok({ trips });
|
|
}
|
|
);
|
|
|
|
// --- TRIP SUMMARY ---
|
|
|
|
server.registerTool(
|
|
'get_trip_summary',
|
|
{
|
|
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const summary = getTripSummary(tripId);
|
|
if (!summary) return noAccess();
|
|
// Addon availability gates
|
|
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
|
|
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
|
|
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
|
|
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
|
|
// Core trip data (metadata, days, members, accommodations) is always included
|
|
// because this tool is always registered and needed for navigation.
|
|
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
|
|
const canReadPacking = packingEnabled && canRead(scopes, 'packing');
|
|
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
|
|
const canReadTodos = packingEnabled && canRead(scopes, 'todos');
|
|
const canReadRes = canRead(scopes, 'reservations');
|
|
const todos = canReadTodos ? listTodoItems(tripId) : [];
|
|
let pollCount = 0;
|
|
let messageCount = 0;
|
|
if (canReadCollab) {
|
|
pollCount = listPolls(tripId).length;
|
|
messageCount = countMessages(tripId);
|
|
}
|
|
const notice = getDeprecationNotice();
|
|
const data = {
|
|
...summary,
|
|
reservations: canReadRes ? summary.reservations : undefined,
|
|
packing: canReadPacking ? summary.packing : undefined,
|
|
budget: canReadBudget ? summary.budget : undefined,
|
|
collab_notes: canReadCollab ? summary.collab_notes : [],
|
|
todos,
|
|
pollCount,
|
|
messageCount,
|
|
};
|
|
if (notice) return {
|
|
isError: true as const,
|
|
content: [
|
|
{ type: 'text' as const, text: notice },
|
|
{ type: 'text' as const, text: JSON.stringify(data, null, 2) },
|
|
],
|
|
};
|
|
return ok({
|
|
...summary,
|
|
reservations: canReadRes ? summary.reservations : undefined,
|
|
packing: canReadPacking ? summary.packing : undefined,
|
|
budget: canReadBudget ? summary.budget : undefined,
|
|
collab_notes: canReadCollab ? summary.collab_notes : [],
|
|
todos,
|
|
pollCount,
|
|
messageCount,
|
|
});
|
|
}
|
|
);
|
|
|
|
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
|
|
|
|
if (R) server.registerTool(
|
|
'list_trip_members',
|
|
{
|
|
description: 'List all members of a trip (owner + collaborators).',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const ownerRow = getTripOwner(tripId);
|
|
if (!ownerRow) return noAccess();
|
|
const { owner, members } = listTripMembers(tripId, ownerRow.user_id);
|
|
return ok({ owner, members });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'add_trip_member',
|
|
{
|
|
description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
identifier: z.string().min(1).describe('Username or email of the user to add'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, identifier }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const ownerRow = getTripOwner(tripId);
|
|
if (!ownerRow || ownerRow.user_id !== userId)
|
|
return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true };
|
|
try {
|
|
const result = addTripMember(tripId, identifier, ownerRow.user_id, userId);
|
|
safeBroadcast(tripId, 'member:added', { member: result.member });
|
|
return ok({ member: result.member });
|
|
} catch (err) {
|
|
const msg = err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.';
|
|
return { content: [{ type: 'text' as const, text: msg }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'remove_trip_member',
|
|
{
|
|
description: 'Remove a member from a trip. Only the trip owner can do this.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
memberId: z.number().int().positive().describe('User ID of the member to remove'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_DELETE,
|
|
},
|
|
async ({ tripId, memberId }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const ownerRow = getTripOwner(tripId);
|
|
if (!ownerRow || ownerRow.user_id !== userId)
|
|
return { content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }], isError: true };
|
|
removeTripMember(tripId, memberId);
|
|
safeBroadcast(tripId, 'member:removed', { userId: memberId });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'copy_trip',
|
|
{
|
|
description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive().describe('Source trip ID to duplicate'),
|
|
title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, title }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
try {
|
|
const newTripId = copyTripById(tripId, userId, title);
|
|
const newTrip = canAccessTrip(newTripId, userId);
|
|
return ok({ trip: { id: newTripId, ...newTrip } });
|
|
} catch {
|
|
return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
if (R) server.registerTool(
|
|
'export_trip_ics',
|
|
{
|
|
description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
try {
|
|
const { ics, filename } = exportICS(tripId);
|
|
return ok({ ics, filename });
|
|
} catch {
|
|
return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true };
|
|
}
|
|
}
|
|
);
|
|
|
|
if (S) server.registerTool(
|
|
'get_share_link',
|
|
{
|
|
description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const link = getShareLink(String(tripId));
|
|
return ok({ link });
|
|
}
|
|
);
|
|
|
|
if (S) server.registerTool(
|
|
'create_share_link',
|
|
{
|
|
description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
share_map: z.boolean().optional().default(true).describe('Share the map and places'),
|
|
share_bookings: z.boolean().optional().default(true).describe('Share reservations'),
|
|
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
|
|
share_budget: z.boolean().optional().default(false).describe('Share budget'),
|
|
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const { token, created } = createOrUpdateShareLink(String(tripId), userId, {
|
|
share_map: share_map ?? true,
|
|
share_bookings: share_bookings ?? true,
|
|
share_packing: share_packing ?? false,
|
|
share_budget: share_budget ?? false,
|
|
share_collab: share_collab ?? false,
|
|
});
|
|
return ok({ token, created });
|
|
}
|
|
);
|
|
|
|
if (S) server.registerTool(
|
|
'delete_share_link',
|
|
{
|
|
description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_DELETE,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
deleteShareLink(String(tripId));
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
}
|