Merge pull request #540 from mauriceboe/feat/mcp-enhancement

feat(mcp): extract all MCP tools into dedicated modules and add shared helpers and add missing tools
This commit is contained in:
Julien G.
2026-04-09 18:29:38 +02:00
committed by GitHub
34 changed files with 7362 additions and 1250 deletions
+172 -4
View File
@@ -3,13 +3,18 @@ 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 } from '../services/budgetService';
import { listItems as listPackingItems } from '../services/packingService';
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 } from '../services/collabService';
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
import { listItems as listTodoItems } from '../services/todoService';
import { listFiles } from '../services/fileService';
import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries } from '../services/atlasService';
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 } from '../services/adminService';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -183,6 +188,32 @@ export function registerResources(server: McpServer, userId: number): void {
}
);
// Trip files (active, not trash)
server.registerResource(
'trip-files',
new ResourceTemplate('trek://trips/{tripId}/files', { list: undefined }),
{ description: 'Active files attached to a trip (excludes trash)', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const files = listFiles(id, false);
return jsonContent(uri.href, files);
}
);
// Trip to-do list
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)
server.registerResource(
'categories',
@@ -215,4 +246,141 @@ export function registerResources(server: McpServer, userId: number): void {
return jsonContent(uri.href, countries);
}
);
// Budget per-person summary
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
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
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
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('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 & messages (addon-gated)
if (isAddonEnabled('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);
}
);
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('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 ?? []);
}
);
}
}
+30 -1093
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
import { broadcast } from '../../websocket';
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
try {
broadcast(tripId, event, payload);
} catch (err) {
console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err);
}
}
export const MAX_MCP_TRIP_DAYS = 90;
export const TOOL_ANNOTATIONS_READONLY = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_WRITE = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_DELETE = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
} as const;
export function demoDenied() {
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
}
export function noAccess() {
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
}
export function ok(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
+175
View File
@@ -0,0 +1,175 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
dayExists, placeExists, createAssignment, assignmentExistsInDay,
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
moveAssignment,
getParticipants as getAssignmentParticipants,
setParticipants as setAssignmentParticipants,
} from '../../services/assignmentService';
import { getDay } from '../../services/dayService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerAssignmentTools(server: McpServer, userId: number): void {
// --- ASSIGNMENTS ---
server.registerTool(
'assign_place_to_day',
{
description: 'Assign a place to a specific day in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
placeId: z.number().int().positive(),
notes: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, dayId, placeId, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
const assignment = createAssignment(dayId, placeId, notes || null);
safeBroadcast(tripId, 'assignment:created', { assignment });
return ok({ assignment });
}
);
server.registerTool(
'unassign_place',
{
description: 'Remove a place assignment from a day.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, dayId, assignmentId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
deleteAssignment(assignmentId);
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
return ok({ success: true });
}
);
server.registerTool(
'update_assignment_time',
{
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, assignmentId, place_time, end_time }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = getAssignmentForTrip(assignmentId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
const assignment = updateTime(
assignmentId,
place_time !== undefined ? place_time : (existing as any).assignment_time,
end_time !== undefined ? end_time : (existing as any).assignment_end_time
);
safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
);
server.registerTool(
'move_assignment',
{
description: 'Move a place assignment to a different day.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
newDayId: z.number().int().positive(),
oldDayId: z.number().int().positive(),
orderIndex: z.number().int().min(0).optional().default(0),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId);
safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId });
return ok({ assignment: result.assignment });
}
);
server.registerTool(
'get_assignment_participants',
{
description: 'Get the list of users participating in a specific place assignment.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, assignmentId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const participants = getAssignmentParticipants(assignmentId);
return ok({ participants });
}
);
server.registerTool(
'set_assignment_participants',
{
description: 'Set the participants for a place assignment (replaces current list).',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
userIds: z.array(z.number().int().positive()).describe('User IDs to set as participants; empty array clears all'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, assignmentId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const participants = setAssignmentParticipants(assignmentId, userIds);
safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants });
return ok({ participants });
}
);
// --- REORDER ---
server.registerTool(
'reorder_day_assignments',
{
description: 'Reorder places within a day by providing the assignment IDs in the desired order.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, dayId, assignmentIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
reorderAssignments(dayId, assignmentIds);
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
return ok({ success: true, dayId, order: assignmentIds });
}
);
}
+192
View File
@@ -0,0 +1,192 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import {
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
getStats as getAtlasStats, listManuallyVisitedRegions,
markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem,
} from '../../services/atlasService';
import { isAddonEnabled } from '../../services/adminService';
import {
TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY,
demoDenied, ok,
} from './_shared';
export function registerAtlasTools(server: McpServer, userId: number): void {
// --- BUCKET LIST ---
server.registerTool(
'create_bucket_list_item',
{
description: 'Add a destination to your personal travel bucket list.',
inputSchema: {
name: z.string().min(1).max(200).describe('Destination or experience name'),
lat: z.number().optional(),
lng: z.number().optional(),
country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ name, lat, lng, country_code, notes }) => {
if (isDemoUser(userId)) return demoDenied();
const item = createBucketItem(userId, { name, lat, lng, country_code, notes });
return ok({ item });
}
);
server.registerTool(
'delete_bucket_list_item',
{
description: 'Remove an item from your travel bucket list.',
inputSchema: {
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ itemId }) => {
if (isDemoUser(userId)) return demoDenied();
const deleted = deleteBucketItem(userId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ success: true });
}
);
// --- ATLAS ---
server.registerTool(
'mark_country_visited',
{
description: 'Mark a country as visited in your Atlas.',
inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
markCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
);
server.registerTool(
'unmark_country_visited',
{
description: 'Remove a country from your visited countries in Atlas.',
inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
unmarkCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
);
// --- ATLAS EXPANDED ---
if (isAddonEnabled('atlas')) {
server.registerTool(
'get_atlas_stats',
{
description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const stats = await getAtlasStats(userId);
return ok({ stats });
}
);
server.registerTool(
'list_visited_regions',
{
description: 'List all manually visited sub-country regions for the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const regions = listManuallyVisitedRegions(userId);
return ok({ regions });
}
);
server.registerTool(
'mark_region_visited',
{
description: 'Mark a sub-country region as visited.',
inputSchema: {
regionCode: z.string().describe('ISO region code e.g. US-CA'),
regionName: z.string(),
countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ regionCode, regionName, countryCode }) => {
if (isDemoUser(userId)) return demoDenied();
markRegionVisited(userId, regionCode, regionName, countryCode);
const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode);
return ok({ region });
}
);
server.registerTool(
'unmark_region_visited',
{
description: 'Remove a region from the visited list.',
inputSchema: {
regionCode: z.string(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ regionCode }) => {
if (isDemoUser(userId)) return demoDenied();
unmarkRegionVisited(userId, regionCode);
return ok({ success: true });
}
);
server.registerTool(
'get_country_atlas_places',
{
description: 'Get places saved in the user\'s atlas for a specific country.',
inputSchema: {
countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ countryCode }) => {
const result = getCountryPlaces(userId, countryCode);
return ok(result);
}
);
server.registerTool(
'update_bucket_list_item',
{
description: 'Update a bucket list item (notes, name, target date, location).',
inputSchema: {
itemId: z.number().int().positive(),
name: z.string().optional(),
notes: z.string().optional(),
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
country_code: z.string().optional(),
target_date: z.string().nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ itemId, name, notes, lat, lng, country_code, target_date }) => {
if (isDemoUser(userId)) return demoDenied();
const item = updateBucketItem(userId, itemId, { name, notes, lat, lng, country_code, target_date });
if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
return ok({ item });
}
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createBudgetItem, updateBudgetItem, deleteBudgetItem,
updateMembers as updateBudgetMembers,
toggleMemberPaid,
} from '../../services/budgetService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerBudgetTools(server: McpServer, userId: number): void {
// --- BUDGET ---
server.registerTool(
'create_budget_item',
{
description: 'Add a budget/expense item to a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createBudgetItem(tripId, { category, name, total_price, note });
safeBroadcast(tripId, 'budget:created', { item });
return ok({ item });
}
);
server.registerTool(
'delete_budget_item',
{
description: 'Delete a budget item from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteBudgetItem(itemId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:deleted', { itemId });
return ok({ success: true });
}
);
// --- BUDGET (update) ---
server.registerTool(
'update_budget_item',
{
description: 'Update an existing budget/expense item in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
total_price: z.number().nonnegative().optional(),
persons: z.number().int().positive().nullable().optional(),
days: z.number().int().positive().nullable().optional(),
note: z.string().max(500).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
safeBroadcast(tripId, 'budget:updated', { item });
return ok({ item });
}
);
// --- BUDGET ADVANCED ---
server.registerTool(
'set_budget_item_members',
{
description: 'Set which trip members are splitting a budget item (replaces current member list).',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
userIds: z.array(z.number().int().positive()).describe('User IDs splitting this item; empty array clears all'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetMembers(itemId, tripId, userIds);
safeBroadcast(tripId, 'budget:members-updated', { item });
return ok({ item });
}
);
server.registerTool(
'toggle_budget_member_paid',
{
description: 'Mark or unmark a member as having paid their share of a budget item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
memberId: z.number().int().positive().describe('User ID of the member'),
paid: z.boolean(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, memberId, paid }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const member = toggleMemberPaid(itemId, memberId, paid);
safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member });
return ok({ member });
}
);
}
+268
View File
@@ -0,0 +1,268 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote,
listPolls, createPoll, votePoll, closePoll, deletePoll,
listMessages, createMessage, deleteMessage, addOrRemoveReaction,
} from '../../services/collabService';
import { isAddonEnabled } from '../../services/adminService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY,
demoDenied, noAccess, ok,
} from './_shared';
export function registerCollabTools(server: McpServer, userId: number): void {
// --- COLLAB NOTES ---
server.registerTool(
'create_collab_note',
{
description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note });
}
);
server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
}
);
server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true });
}
);
// --- COLLAB POLLS & CHAT ---
if (isAddonEnabled('collab')) {
server.registerTool(
'list_collab_polls',
{
description: 'List all polls for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const polls = listPolls(tripId);
return ok({ polls });
}
);
server.registerTool(
'create_collab_poll',
{
description: 'Create a new poll in the collab panel.',
inputSchema: {
tripId: z.number().int().positive(),
question: z.string().min(1),
options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'),
multiple: z.boolean().optional().describe('Allow multiple choice'),
deadline: z.string().optional().describe('ISO date string for poll deadline'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, question, options, multiple, deadline }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const poll = createPoll(tripId, userId, { question, options, multiple, deadline });
safeBroadcast(tripId, 'collab:poll:created', { poll });
return ok({ poll });
}
);
server.registerTool(
'vote_collab_poll',
{
description: 'Vote on a poll option (or remove vote if already voted for that option).',
inputSchema: {
tripId: z.number().int().positive(),
pollId: z.number().int().positive(),
optionIndex: z.number().int().min(0).describe('Zero-based index of the option to vote for'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, pollId, optionIndex }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = votePoll(tripId, pollId, userId, optionIndex);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll });
return ok({ poll: result.poll });
}
);
server.registerTool(
'close_collab_poll',
{
description: 'Close a poll so no more votes can be cast.',
inputSchema: {
tripId: z.number().int().positive(),
pollId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, pollId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const poll = closePoll(tripId, pollId);
if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:closed', { poll });
return ok({ poll });
}
);
server.registerTool(
'delete_collab_poll',
{
description: 'Delete a poll and all its votes.',
inputSchema: {
tripId: z.number().int().positive(),
pollId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, pollId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePoll(tripId, pollId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true };
safeBroadcast(tripId, 'collab:poll:deleted', { pollId });
return ok({ success: true });
}
);
server.registerTool(
'list_collab_messages',
{
description: 'List chat messages for a trip (most recent 100, oldest-first).',
inputSchema: {
tripId: z.number().int().positive(),
before: z.number().int().positive().optional().describe('Load messages with ID less than this (pagination)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, before }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const messages = listMessages(tripId, before);
return ok({ messages });
}
);
server.registerTool(
'send_collab_message',
{
description: "Send a chat message to a trip's collab channel.",
inputSchema: {
tripId: z.number().int().positive(),
text: z.string().min(1),
replyTo: z.number().int().positive().optional().describe('Reply to a specific message ID'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, text, replyTo }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = createMessage(tripId, userId, text, replyTo ?? null);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:created', { message: result.message });
return ok({ message: result.message });
}
);
server.registerTool(
'delete_collab_message',
{
description: 'Delete a chat message (only the message owner can delete their own messages).',
inputSchema: {
tripId: z.number().int().positive(),
messageId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, messageId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = deleteMessage(tripId, messageId, userId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username });
return ok({ success: true });
}
);
server.registerTool(
'react_collab_message',
{
description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).',
inputSchema: {
tripId: z.number().int().positive(),
messageId: z.number().int().positive(),
emoji: z.string().describe('Single emoji character'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, messageId, emoji }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const result = addOrRemoveReaction(messageId, tripId, userId, emoji);
if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true };
safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions });
return ok({ reactions: result.reactions });
}
);
}
}
+229
View File
@@ -0,0 +1,229 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
getDay, updateDay, validateAccommodationRefs,
createDay, deleteDay,
createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation,
} from '../../services/dayService';
import {
createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote,
deleteNote as deleteDayNote, dayExists as dayNoteExists,
} from '../../services/dayNoteService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerDayTools(server: McpServer, userId: number): void {
// --- DAYS ---
server.registerTool(
'update_day',
{
description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
title: z.string().max(200).nullable().describe('Day title, or null to clear it'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, dayId, title }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const current = getDay(dayId, tripId);
if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
safeBroadcast(tripId, 'day:updated', { day: updated });
return ok({ day: updated });
}
);
server.registerTool(
'create_day',
{
description: 'Add a new day to a trip (optionally with a specific date and notes).',
inputSchema: {
tripId: z.number().int().positive(),
date: z.string().optional().describe('ISO date string YYYY-MM-DD, optional for dateless trips'),
notes: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, date, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const day = createDay(tripId, date, notes);
safeBroadcast(tripId, 'day:created', { day });
return ok({ day });
}
);
server.registerTool(
'delete_day',
{
description: 'Delete a day from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, dayId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
deleteDay(dayId);
safeBroadcast(tripId, 'day:deleted', { id: dayId });
return ok({ success: true });
}
);
server.registerTool(
'create_accommodation',
{
description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range.',
inputSchema: {
tripId: z.number().int().positive(),
place_id: z.number().int().positive().describe('The place to use as the accommodation'),
start_day_id: z.number().int().positive().describe('Check-in day ID'),
end_day_id: z.number().int().positive().describe('Check-out day ID'),
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true };
const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
safeBroadcast(tripId, 'accommodation:created', { accommodation });
return ok({ accommodation });
}
);
server.registerTool(
'update_accommodation',
{
description: 'Update fields on an existing accommodation.',
inputSchema: {
tripId: z.number().int().positive(),
accommodationId: z.number().int().positive(),
place_id: z.number().int().positive().optional(),
start_day_id: z.number().int().positive().optional(),
end_day_id: z.number().int().positive().optional(),
check_in: z.string().max(10).optional(),
check_out: z.string().max(10).optional(),
confirmation: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = getAccommodation(accommodationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
safeBroadcast(tripId, 'accommodation:updated', { accommodation });
return ok({ accommodation });
}
);
server.registerTool(
'delete_accommodation',
{
description: 'Delete an accommodation from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
accommodationId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, accommodationId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const { linkedReservationId } = deleteAccommodation(accommodationId);
safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId });
return ok({ success: true, linkedReservationId });
}
);
// --- DAY NOTES ---
server.registerTool(
'create_day_note',
{
description: 'Add a note to a specific day in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
text: z.string().min(1).max(500),
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, dayId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = createDayNote(dayId, tripId, text, time, icon);
safeBroadcast(tripId, 'dayNote:created', { dayId, note });
return ok({ note });
}
);
server.registerTool(
'update_day_note',
{
description: 'Edit an existing note on a specific day.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
noteId: z.number().int().positive(),
text: z.string().min(1).max(500).optional(),
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, dayId, noteId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = getDayNote(noteId, dayId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
safeBroadcast(tripId, 'dayNote:updated', { dayId, note });
return ok({ note });
}
);
server.registerTool(
'delete_day_note',
{
description: 'Delete a note from a specific day.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
noteId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, dayId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = getDayNote(noteId, dayId, tripId);
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
deleteDayNote(noteId);
safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId });
return ok({ success: true });
}
);
}
+109
View File
@@ -0,0 +1,109 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
import {
TOOL_ANNOTATIONS_READONLY,
ok,
} from './_shared';
export function registerMapsWeatherTools(server: McpServer, userId: number): void {
// --- MAPS EXTRAS ---
server.registerTool(
'get_place_details',
{
description: 'Fetch detailed information about a place by its Google Place ID.',
inputSchema: {
placeId: z.string().describe('Google Place ID'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ placeId, lang }) => {
const details = await getPlaceDetails(userId, placeId, lang ?? 'en');
if (!details) return { content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }], isError: true };
return ok({ details });
}
);
server.registerTool(
'reverse_geocode',
{
description: 'Get a human-readable address for given coordinates.',
inputSchema: {
lat: z.number(),
lng: z.number(),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ lat, lng, lang }) => {
const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en');
if (!result) return { content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }], isError: true };
return ok(result);
}
);
server.registerTool(
'resolve_maps_url',
{
description: 'Resolve a Google Maps share URL to coordinates and place name.',
inputSchema: {
url: z.string().describe('Google Maps share URL'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ url }) => {
const result = await resolveGoogleMapsUrl(url);
if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true };
return ok(result);
}
);
// --- WEATHER ---
server.registerTool(
'get_weather',
{
description: 'Get weather forecast for a location and date.',
inputSchema: {
lat: z.number(),
lng: z.number(),
date: z.string().describe('ISO date YYYY-MM-DD'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ lat, lng, date, lang }) => {
try {
const weather = await getWeather(String(lat), String(lng), date, lang ?? 'en');
return ok({ weather });
} catch (err: any) {
return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true };
}
}
);
server.registerTool(
'get_detailed_weather',
{
description: 'Get hourly/detailed weather forecast for a location and date.',
inputSchema: {
lat: z.number(),
lng: z.number(),
date: z.string().describe('ISO date YYYY-MM-DD'),
lang: z.string().optional().default('en'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ lat, lng, date, lang }) => {
try {
const weather = await getDetailedWeather(String(lat), String(lng), date, lang ?? 'en');
return ok({ weather });
} catch (err: any) {
return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true };
}
}
);
}
+95
View File
@@ -0,0 +1,95 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import {
getNotifications, getUnreadCount,
markRead as markNotificationRead, markUnread as markNotificationUnread,
markAllRead,
} from '../../services/inAppNotifications';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
export function registerNotificationTools(server: McpServer, userId: number): void {
// --- NOTIFICATIONS ---
server.registerTool(
'list_notifications',
{
description: 'List in-app notifications for the current user.',
inputSchema: {
limit: z.number().int().positive().optional().default(20),
offset: z.number().int().min(0).optional().default(0),
unread_only: z.boolean().optional().default(false),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ limit, offset, unread_only }) => {
const result = getNotifications(userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false });
return ok(result);
}
);
server.registerTool(
'get_unread_notification_count',
{
description: 'Get the number of unread in-app notifications.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const count = getUnreadCount(userId);
return ok({ count });
}
);
server.registerTool(
'mark_notification_read',
{
description: 'Mark a single notification as read.',
inputSchema: {
notificationId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ notificationId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = markNotificationRead(notificationId, userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
}
);
server.registerTool(
'mark_notification_unread',
{
description: 'Mark a single notification as unread.',
inputSchema: {
notificationId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ notificationId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = markNotificationUnread(notificationId, userId);
if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true };
return ok({ success: true });
}
);
server.registerTool(
'mark_all_notifications_read',
{
description: "Mark all of the current user's notifications as read.",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async () => {
if (isDemoUser(userId)) return demoDenied();
const count = markAllRead(userId);
return ok({ success: true, count });
}
);
}
+326
View File
@@ -0,0 +1,326 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createItem as createPackingItem, updateItem as updatePackingItem,
deleteItem as deletePackingItem,
reorderItems as reorderPackingItems,
listBags, createBag, updateBag, deleteBag, setBagMembers,
getCategoryAssignees as getPackingCategoryAssignees,
updateCategoryAssignees as updatePackingCategoryAssignees,
applyTemplate, saveAsTemplate, bulkImport,
} from '../../services/packingService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerPackingTools(server: McpServer, userId: number): void {
// --- PACKING ---
server.registerTool(
'create_packing_item',
{
description: 'Add an item to the packing checklist for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createPackingItem(tripId, { name, category: category || 'General' });
safeBroadcast(tripId, 'packing:created', { item });
return ok({ item });
}
);
server.registerTool(
'toggle_packing_item',
{
description: 'Check or uncheck a packing item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
checked: z.boolean(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
server.registerTool(
'delete_packing_item',
{
description: 'Remove an item from the packing checklist.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePackingItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:deleted', { itemId });
return ok({ success: true });
}
);
// --- PACKING (update) ---
server.registerTool(
'update_packing_item',
{
description: 'Rename a packing item or change its category.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
// --- PACKING ADVANCED ---
server.registerTool(
'reorder_packing_items',
{
description: 'Set the display order of packing items within a trip.',
inputSchema: {
tripId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()).describe('Packing item IDs in desired order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
reorderPackingItems(tripId, orderedIds);
safeBroadcast(tripId, 'packing:reordered', { orderedIds });
return ok({ success: true });
}
);
server.registerTool(
'list_packing_bags',
{
description: 'List all packing bags for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const bags = listBags(tripId);
return ok({ bags });
}
);
server.registerTool(
'create_packing_bag',
{
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(100),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const bag = createBag(tripId, { name, color });
safeBroadcast(tripId, 'packing:bag-created', { bag });
return ok({ bag });
}
);
server.registerTool(
'update_packing_bag',
{
description: 'Rename or recolor a packing bag.',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, bagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const fields: Record<string, unknown> = {};
const bodyKeys: string[] = [];
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
const bag = updateBag(tripId, bagId, fields, bodyKeys);
safeBroadcast(tripId, 'packing:bag-updated', { bag });
return ok({ bag });
}
);
server.registerTool(
'delete_packing_bag',
{
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, bagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
deleteBag(tripId, bagId);
safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId });
return ok({ success: true });
}
);
server.registerTool(
'set_bag_members',
{
description: 'Assign trip members to a packing bag (determines who packs what bag).',
inputSchema: {
tripId: z.number().int().positive(),
bagId: z.number().int().positive(),
userIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, bagId, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
setBagMembers(tripId, bagId, userIds);
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
return ok({ success: true });
}
);
server.registerTool(
'get_packing_category_assignees',
{
description: 'Get which trip members are assigned to each packing category.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = getPackingCategoryAssignees(tripId);
return ok({ assignees });
}
);
server.registerTool(
'set_packing_category_assignees',
{
description: 'Assign trip members to a packing category.',
inputSchema: {
tripId: z.number().int().positive(),
categoryName: z.string().min(1).max(100),
userIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
updatePackingCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
return ok({ success: true });
}
);
server.registerTool(
'apply_packing_template',
{
description: 'Apply a packing template to a trip (adds items from the template).',
inputSchema: {
tripId: z.number().int().positive(),
templateId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, templateId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const applied = applyTemplate(tripId, templateId);
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
safeBroadcast(tripId, 'packing:template-applied', { templateId });
return ok({ success: true });
}
);
server.registerTool(
'save_packing_template',
{
description: 'Save the current packing list as a reusable template.',
inputSchema: {
tripId: z.number().int().positive(),
templateName: z.string().min(1).max(100),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, templateName }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
saveAsTemplate(tripId, userId, templateName);
return ok({ success: true });
}
);
server.registerTool(
'bulk_import_packing',
{
description: 'Import multiple packing items at once from a list.',
inputSchema: {
tripId: z.number().int().positive(),
items: z.array(z.object({
name: z.string().min(1).max(200),
category: z.string().optional(),
quantity: z.number().int().positive().optional(),
})).min(1),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, items }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
bulkImport(tripId, items);
safeBroadcast(tripId, 'packing:updated', {});
return ok({ success: true, count: items.length });
}
);
}
+158
View File
@@ -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 { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
import { listCategories } from '../../services/categoryService';
import { searchPlaces } from '../../services/mapsService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerPlaceTools(server: McpServer, userId: number): void {
// --- PLACES ---
server.registerTool(
'create_place',
{
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
);
server.registerTool(
'update_place',
{
description: 'Update an existing place in a trip.',
inputSchema: {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
name: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
price: z.number().optional(),
currency: z.string().length(3).optional(),
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
duration_minutes: z.number().int().positive().optional(),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
}
);
server.registerTool(
'delete_place',
{
description: 'Delete a place from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, placeId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:deleted', { placeId });
return ok({ success: true });
}
);
server.registerTool(
'list_places',
{
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
inputSchema: {
tripId: z.number().int().positive(),
search: z.string().optional(),
category: z.string().optional(),
tag: z.string().optional(),
assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, search, category, tag, assignment }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const places = listPlaces(String(tripId), { search, category, tag, assignment });
return ok({ places });
}
);
// --- CATEGORIES ---
server.registerTool(
'list_categories',
{
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const categories = listCategories();
return ok({ categories });
}
);
// --- SEARCH ---
server.registerTool(
'search_place',
{
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.',
inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ query }) => {
try {
const result = await searchPlaces(userId, query);
return ok(result);
} catch {
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
}
}
);
}
+116
View File
@@ -0,0 +1,116 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { getTripSummary } from '../../services/tripService';
import { listItems as listPackingItems } from '../../services/packingService';
export function registerMcpPrompts(server: McpServer, _userId: number): void {
const userId = _userId;
server.registerPrompt(
'trip-summary',
{
title: 'Trip Summary',
description: 'Load a full summary of a trip for context before planning or modifications',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID to summarize'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collabNotes } = summary;
const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 };
const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0;
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packingStats.packed}/${packingStats.total} items packed
Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collabNotes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text } }],
};
}
);
server.registerPrompt(
'packing-list',
{
title: 'Packing List',
description: 'Get a formatted packing checklist for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const items = listPackingItems(tripId);
if (!items.length) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] };
}
const grouped = items.reduce((acc: Record<string, any[]>, item: any) => {
const cat = item.category || 'General';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
const lines = Object.entries(grouped).map(([cat, items]) =>
`## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
).join('\n\n');
const { trip } = getTripSummary(tripId) || {};
return {
description: `Packing list for "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
};
}
);
server.registerPrompt(
'budget-overview',
{
title: 'Budget Overview',
description: 'Get a formatted budget summary for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, budget } = summary;
const currency = trip?.currency || 'EUR';
const byCategory = (budget || []).reduce((acc: Record<string, number>, item: any) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {} as Record<string, number>);
const total = Object.values(byCategory).reduce((s, v) => s + v, 0);
const lines = Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
.join('\n');
const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2);
return {
description: `Budget overview for "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
};
}
);
}
+203
View File
@@ -0,0 +1,203 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createReservation, getReservation, updateReservation, deleteReservation,
updatePositions as updateReservationPositions,
} from '../../services/reservationService';
import { getDay } from '../../services/dayService';
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
import {
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerReservationTools(server: McpServer, userId: number): void {
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.',
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"'),
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(),
notes: z.string().max(1000).optional(),
day_id: z.number().int().positive().optional(),
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
// Validate that all referenced IDs belong to this trip
if (day_id && !getDay(day_id, tripId))
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
if (place_id && !placeExists(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], 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 };
if (assignment_id && !getAssignmentForTrip(assignment_id, tripId))
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
: undefined;
const { reservation, accommodationCreated } = createReservation(tripId, {
title, type, reservation_time, location, confirmation_number,
notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation,
});
if (accommodationCreated) {
safeBroadcast(tripId, 'accommodation:created', {});
}
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
);
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.',
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"'),
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(),
notes: z.string().max(1000).optional(),
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
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: 'Reservation not found.' }], isError: true };
if (place_id != null && !placeExists(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
const { reservation } = updateReservation(reservationId, tripId, {
title, type, reservation_time, location, confirmation_number, notes, status,
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
}, existing);
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation });
}
);
server.registerTool(
'delete_reservation',
{
description: 'Delete a reservation 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, accommodationDeleted } = deleteReservation(reservationId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (accommodationDeleted) {
safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
}
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true });
}
);
server.registerTool(
'reorder_reservations',
{
description: 'Update the display order of reservations within a day.',
inputSchema: {
tripId: z.number().int().positive(),
positions: z.array(z.object({
id: z.number().int().positive(),
day_plan_position: z.number().int().min(0),
})).describe('Array of { id, day_plan_position } pairs'),
dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, positions, dayId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
updateReservationPositions(tripId, positions, dayId);
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
return ok({ success: true });
}
);
server.registerTool(
'link_hotel_accommodation',
{
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
place_id: z.number().int().positive().describe('The hotel place to link'),
start_day_id: z.number().int().positive().describe('Check-in day ID'),
end_day_id: z.number().int().positive().describe('Check-out day ID'),
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const current = getReservation(reservationId, tripId);
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
if (!placeExists(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
if (!getDay(start_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
if (!getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
const isNewAccommodation = !current.accommodation_id;
const { reservation } = updateReservation(reservationId, tripId, {
place_id,
type: current.type,
status: current.status as string,
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
}, current);
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
}
);
}
+78
View File
@@ -0,0 +1,78 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { listTags, createTag, updateTag, deleteTag } from '../../services/tagService';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
export function registerTagTools(server: McpServer, userId: number): void {
// --- TAGS ---
server.registerTool(
'list_tags',
{
description: 'List all tags belonging to the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const tags = listTags(userId);
return ok({ tags });
}
);
server.registerTool(
'create_tag',
{
description: 'Create a new tag (user-scoped label for places).',
inputSchema: {
name: z.string().min(1).max(100),
color: z.string().optional().describe('Hex color string e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ name, color }) => {
if (isDemoUser(userId)) return demoDenied();
const tag = createTag(userId, name, color);
return ok({ tag });
}
);
server.registerTool(
'update_tag',
{
description: 'Update the name or color of an existing tag.',
inputSchema: {
tagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
const tag = updateTag(tagId, name, color);
if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
return ok({ tag });
}
);
server.registerTool(
'delete_tag',
{
description: 'Delete a tag (removes it from all places it was attached to).',
inputSchema: {
tagId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tagId }) => {
if (isDemoUser(userId)) return demoDenied();
deleteTag(tagId);
return ok({ success: true });
}
);
}
+185
View File
@@ -0,0 +1,185 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
listItems as listTodoItems, createItem as createTodoItem, updateItem as updateTodoItem,
deleteItem as deleteTodoItem, reorderItems as reorderTodoItems,
getCategoryAssignees as getTodoCategoryAssignees, updateCategoryAssignees as updateTodoCategoryAssignees,
} from '../../services/todoService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerTodoTools(server: McpServer, userId: number): void {
// --- TODOS ---
server.registerTool(
'list_todos',
{
description: 'List all to-do items for a trip, ordered by position.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const items = listTodoItems(tripId);
return ok({ items });
}
);
server.registerTool(
'create_todo',
{
description: 'Create a new to-do item for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(500).describe('To-do item name'),
category: z.string().max(100).optional().describe('Category (e.g. "Logistics", "Booking")'),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Due date (YYYY-MM-DD)'),
description: z.string().max(2000).optional().describe('Additional description'),
assigned_user_id: z.number().int().positive().optional().describe('User ID to assign this task to'),
priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=low, 2=medium, 3=high'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
safeBroadcast(tripId, 'todo:created', { item });
return ok({ item });
}
);
server.registerTool(
'update_todo',
{
description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(500).optional(),
category: z.string().max(100).optional(),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional().describe('Set to null to clear the due date'),
description: z.string().max(2000).nullable().optional().describe('Set to null to clear'),
assigned_user_id: z.number().int().positive().nullable().optional().describe('Set to null to unassign'),
priority: z.number().int().min(0).max(3).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
// Build bodyKeys to signal which nullable fields were explicitly provided
const bodyKeys: string[] = [];
if (due_date !== undefined) bodyKeys.push('due_date');
if (description !== undefined) bodyKeys.push('description');
if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id');
if (priority !== undefined) bodyKeys.push('priority');
const item = updateTodoItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys);
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:updated', { item });
return ok({ item });
}
);
server.registerTool(
'toggle_todo',
{
description: 'Mark a to-do item as checked (done) or unchecked.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
checked: z.boolean().describe('True to mark done, false to uncheck'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateTodoItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:updated', { item });
return ok({ item });
}
);
server.registerTool(
'delete_todo',
{
description: 'Delete a to-do item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteTodoItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:deleted', { itemId });
return ok({ success: true });
}
);
server.registerTool(
'reorder_todos',
{
description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.',
inputSchema: {
tripId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()).min(1).describe('All item IDs in the desired order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
reorderTodoItems(tripId, orderedIds);
return ok({ success: true });
}
);
server.registerTool(
'get_todo_category_assignees',
{
description: 'Get the default assignees configured per to-do category for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = getTodoCategoryAssignees(tripId);
return ok({ assignees });
}
);
server.registerTool(
'set_todo_category_assignees',
{
description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.',
inputSchema: {
tripId: z.number().int().positive(),
categoryName: z.string().min(1).max(100).describe('Category name'),
userIds: z.array(z.number().int().positive()).describe('User IDs to assign as defaults for this category'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
return ok({ assignees });
}
);
}
+338
View File
@@ -0,0 +1,338 @@
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 { countMessages, listPolls } from '../../services/collabService';
import {
listItems as listTodoItems,
} from '../../services/todoService';
import { listFiles } from '../../services/fileService';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok,
} from './_shared';
export function registerTripTools(server: McpServer, userId: number): void {
// --- TRIPS ---
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 });
}
);
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 });
}
);
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 });
}
);
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 trips = listTrips(userId, include_archived ? null : 0);
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, full budget line items with totals, full packing list with checked status, reservations, collab notes, to-do items, files, and collab poll/message counts. 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();
const todos = listTodoItems(tripId);
const files = listFiles(tripId, false).map((f: any) => ({
id: f.id,
original_name: f.original_name,
mime_type: f.mime_type,
file_size: f.file_size,
starred: !!f.starred,
deleted: !!f.deleted_at,
created_at: f.created_at,
}));
let pollCount = 0;
if (isAddonEnabled('collab')) {
pollCount = listPolls(tripId).length;
}
let messageCount = 0;
if (isAddonEnabled('collab')) {
messageCount = countMessages(tripId);
}
return ok({ ...summary, todos, files, pollCount, messageCount });
}
);
// --- TRIP MEMBERS, COPY, ICS, SHARE ---
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 });
}
);
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 };
}
}
);
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 });
}
);
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 };
}
}
);
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 };
}
}
);
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 });
}
);
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 });
}
);
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 });
}
);
}
+393
View File
@@ -0,0 +1,393 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser, getCurrentUser } from '../../services/authService';
import {
getOwnPlan, getActivePlan, getActivePlanId, getPlanData,
updatePlan, setUserColor,
sendInvite as sendVacayInvite, acceptInvite, declineInvite, cancelInvite, dissolvePlan,
getAvailableUsers,
listYears, addYear, deleteYear,
getEntries as getVacayEntries, toggleEntry, toggleCompanyHoliday,
getStats as getVacayStats, updateStats as updateVacayStats,
addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar,
getCountries as getHolidayCountries, getHolidays,
} from '../../services/vacayService';
import { isAddonEnabled } from '../../services/adminService';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
export function registerVacayTools(server: McpServer, userId: number): void {
if (isAddonEnabled('vacay')) {
server.registerTool(
'get_vacay_plan',
{
description: "Get the current user's active vacation plan (own or joined).",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const plan = getPlanData(userId);
return ok({ plan });
}
);
server.registerTool(
'update_vacay_plan',
{
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
inputSchema: {
block_weekends: z.boolean().optional(),
holidays_enabled: z.boolean().optional(),
holidays_region: z.string().nullable().optional(),
company_holidays_enabled: z.boolean().optional(),
carry_over_enabled: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok({ success: true });
}
);
server.registerTool(
'set_vacay_color',
{
description: "Set the current user's color in the vacation plan calendar.",
inputSchema: {
color: z.string().describe('Hex color e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
setUserColor(userId, planId, color, undefined);
return ok({ success: true });
}
);
server.registerTool(
'get_available_vacay_users',
{
description: 'List users who can be invited to the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const planId = getActivePlanId(userId);
const users = getAvailableUsers(userId, planId);
return ok({ users });
}
);
server.registerTool(
'send_vacay_invite',
{
description: 'Invite a user to join the vacation plan by their user ID.',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const me = getCurrentUser(userId);
if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true };
const result = sendVacayInvite(planId, userId, me.username, me.email, targetUserId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
server.registerTool(
'accept_vacay_invite',
{
description: 'Accept a pending invitation to join another user\'s vacation plan.',
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ planId }) => {
if (isDemoUser(userId)) return demoDenied();
const result = acceptInvite(userId, planId, undefined);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
server.registerTool(
'decline_vacay_invite',
{
description: 'Decline a pending vacation plan invitation.',
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ planId }) => {
declineInvite(userId, planId, undefined);
return ok({ success: true });
}
);
server.registerTool(
'cancel_vacay_invite',
{
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
cancelInvite(planId, targetUserId);
return ok({ success: true });
}
);
server.registerTool(
'dissolve_vacay_plan',
{
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async () => {
if (isDemoUser(userId)) return demoDenied();
dissolvePlan(userId, undefined);
return ok({ success: true });
}
);
server.registerTool(
'list_vacay_years',
{
description: 'List calendar years tracked in the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const planId = getActivePlanId(userId);
const years = listYears(planId);
return ok({ years });
}
);
server.registerTool(
'add_vacay_year',
{
description: 'Add a calendar year to the vacation plan.',
inputSchema: {
year: z.number().int().min(2000).max(2100),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = addYear(planId, year, undefined);
return ok({ years });
}
);
server.registerTool(
'delete_vacay_year',
{
description: 'Remove a calendar year from the vacation plan.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = deleteYear(planId, year, undefined);
return ok({ years });
}
);
server.registerTool(
'get_vacay_entries',
{
description: 'Get all vacation day entries for a plan and year.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const entries = getVacayEntries(planId, String(year));
return ok({ entries });
}
);
server.registerTool(
'toggle_vacay_entry',
{
description: 'Toggle a day on or off as a vacation day for the current user.',
inputSchema: {
date: z.string().describe('ISO date YYYY-MM-DD'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleEntry(userId, planId, date, undefined);
return ok(result);
}
);
server.registerTool(
'toggle_company_holiday',
{
description: 'Toggle a date as a company holiday for the whole plan.',
inputSchema: {
date: z.string(),
note: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date, note }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleCompanyHoliday(planId, date, note, undefined);
return ok(result);
}
);
server.registerTool(
'get_vacay_stats',
{
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const stats = getVacayStats(planId, year);
return ok({ stats });
}
);
server.registerTool(
'update_vacay_stats',
{
description: 'Update the vacation day allowance for a specific user and year.',
inputSchema: {
year: z.number().int(),
vacationDays: z.number().int().min(0),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ year, vacationDays }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
updateVacayStats(userId, planId, year, vacationDays, undefined);
return ok({ success: true });
}
);
server.registerTool(
'add_holiday_calendar',
{
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
inputSchema: {
region: z.string().describe('Country/region code e.g. US, GB, DE'),
label: z.string().nullable().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ region, label, color, sortOrder }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const calendar = addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined);
return ok({ calendar });
}
);
server.registerTool(
'update_holiday_calendar',
{
description: 'Update label or color for a holiday calendar.',
inputSchema: {
calendarId: z.number().int().positive(),
label: z.string().nullable().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ calendarId, label, color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const cal = updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return { content: [{ type: 'text' as const, text: 'Holiday calendar not found.' }], isError: true };
return ok({ calendar: cal });
}
);
server.registerTool(
'delete_holiday_calendar',
{
description: 'Remove a holiday calendar from the vacation plan.',
inputSchema: {
calendarId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ calendarId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
}
);
server.registerTool(
'list_holiday_countries',
{
description: 'List countries available for public holiday calendars.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const result = await getHolidayCountries();
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ countries: result.data });
}
);
server.registerTool(
'list_holidays',
{
description: 'List public holidays for a country and year.',
inputSchema: {
country: z.string().describe('ISO 3166-1 alpha-2 code'),
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ country, year }) => {
const result = await getHolidays(String(year), country);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ holidays: result.data });
}
);
}
}
+3 -153
View File
@@ -23,6 +23,7 @@ import {
addMember,
removeMember,
exportICS,
copyTripById,
verifyTripAccess,
NotFoundError,
ValidationError,
@@ -199,160 +200,9 @@ router.post('/:id/copy', authenticate, (req: Request, res: Response) => {
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!src) return res.status(404).json({ error: 'Trip not found' });
const title = req.body.title || src.title;
const copyTrip = db.transaction(() => {
// 1. Create new trip
const tripResult = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
`).run(authReq.user.id, title, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3);
const newTripId = tripResult.lastInsertRowid;
// 2. Copy days → build ID map
const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(req.params.id) as any[];
const dayMap = new Map<number, number | bigint>();
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)');
for (const d of oldDays) {
const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title);
dayMap.set(d.id, r.lastInsertRowid);
}
// 3. Copy places → build ID map
const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(req.params.id) as any[];
const placeMap = new Map<number, number | bigint>();
const insertPlace = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const p of oldPlaces) {
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
p.website, p.phone, p.transport_mode, p.osm_id);
placeMap.set(p.id, r.lastInsertRowid);
}
// 4. Copy place_tags
const oldTags = db.prepare(`
SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?
`).all(req.params.id) as any[];
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const t of oldTags) {
const newPlaceId = placeMap.get(t.place_id);
if (newPlaceId) insertTag.run(newPlaceId, t.tag_id);
}
// 5. Copy day_assignments → build ID map
const oldAssignments = db.prepare(`
SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?
`).all(req.params.id) as any[];
const assignmentMap = new Map<number, number | bigint>();
const insertAssignment = db.prepare(`
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of oldAssignments) {
const newDayId = dayMap.get(a.day_id);
const newPlaceId = placeMap.get(a.place_id);
if (newDayId && newPlaceId) {
const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes,
a.reservation_status, a.reservation_notes, a.reservation_datetime,
a.assignment_time, a.assignment_end_time);
assignmentMap.set(a.id, r.lastInsertRowid);
}
}
// 6. Copy day_accommodations → build ID map (before reservations, which reference them)
const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(req.params.id) as any[];
const accomMap = new Map<number, number | bigint>();
const insertAccom = db.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of oldAccom) {
const newPlaceId = placeMap.get(a.place_id);
const newStartDay = dayMap.get(a.start_day_id);
const newEndDay = dayMap.get(a.end_day_id);
if (newPlaceId && newStartDay && newEndDay) {
const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes);
accomMap.set(a.id, r.lastInsertRowid);
}
}
// 7. Copy reservations
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
const insertReservation = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
location, confirmation_number, notes, status, type, metadata, day_plan_position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const r of oldReservations) {
insertReservation.run(newTripId,
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
r.title, r.reservation_time, r.reservation_end_time,
r.location, r.confirmation_number, r.notes, r.status, r.type,
r.metadata, r.day_plan_position);
}
// 8. Copy budget_items (paid_by_user_id reset to null)
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(req.params.id) as any[];
const insertBudget = db.prepare(`
INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const b of oldBudget) {
insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order);
}
// 9. Copy packing_bags → build ID map
const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(req.params.id) as any[];
const bagMap = new Map<number, number | bigint>();
const insertBag = db.prepare(`
INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order)
VALUES (?, ?, ?, ?, ?)
`);
for (const bag of oldBags) {
const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order);
bagMap.set(bag.id, r.lastInsertRowid);
}
// 10. Copy packing_items (checked reset to 0)
const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(req.params.id) as any[];
const insertPacking = db.prepare(`
INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id)
VALUES (?, ?, 0, ?, ?, ?, ?)
`);
for (const p of oldPacking) {
insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams,
p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null);
}
// 11. Copy day_notes
const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(req.params.id) as any[];
const insertNote = db.prepare(`
INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const n of oldNotes) {
const newDayId = dayMap.get(n.day_id);
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
}
return newTripId;
});
try {
const newTripId = copyTrip();
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId: Number(newTripId), title } });
const newTripId = copyTripById(req.params.id, authReq.user.id, req.body.title);
writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId, title: req.body.title } });
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId });
res.status(201).json({ trip });
} catch {
+5
View File
@@ -318,6 +318,11 @@ export function formatMessage(msg: CollabMessage, reactions?: GroupedReaction[])
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
}
export function countMessages(tripId: string | number): number {
const row = db.prepare('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?').get(tripId) as { cnt: number };
return row.cnt;
}
export function listMessages(tripId: string | number, before?: string | number) {
const query = `
SELECT m.*, u.username, u.avatar,
+154
View File
@@ -431,6 +431,158 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
return { ics, filename: `${safeFilename}.ics` };
}
// ── Copy / duplicate ─────────────────────────────────────────────────────
/**
* Duplicates a trip (all days, places, assignments, accommodations, reservations,
* budget, packing bags/items, day notes) into a new trip owned by `newOwnerId`.
* Packing items are reset to unchecked. Budget paid status is cleared.
* Returns the new trip's ID.
*/
export function copyTripById(sourceTripId: string | number, newOwnerId: number, title?: string): number {
const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(sourceTripId) as any;
if (!src) throw new NotFoundError('Trip not found');
const newTitle = title || src.title;
const fn = db.transaction(() => {
const tripResult = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)
`).run(newOwnerId, newTitle, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3);
const newTripId = tripResult.lastInsertRowid;
const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(sourceTripId) as any[];
const dayMap = new Map<number, number | bigint>();
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)');
for (const d of oldDays) {
const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title);
dayMap.set(d.id, r.lastInsertRowid);
}
const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(sourceTripId) as any[];
const placeMap = new Map<number, number | bigint>();
const insertPlace = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const p of oldPlaces) {
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
p.website, p.phone, p.transport_mode, p.osm_id);
placeMap.set(p.id, r.lastInsertRowid);
}
const oldTags = db.prepare(`
SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?
`).all(sourceTripId) as any[];
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const t of oldTags) {
const newPlaceId = placeMap.get(t.place_id);
if (newPlaceId) insertTag.run(newPlaceId, t.tag_id);
}
const oldAssignments = db.prepare(`
SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?
`).all(sourceTripId) as any[];
const assignmentMap = new Map<number, number | bigint>();
const insertAssignment = db.prepare(`
INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of oldAssignments) {
const newDayId = dayMap.get(a.day_id);
const newPlaceId = placeMap.get(a.place_id);
if (newDayId && newPlaceId) {
const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes,
a.reservation_status, a.reservation_notes, a.reservation_datetime,
a.assignment_time, a.assignment_end_time);
assignmentMap.set(a.id, r.lastInsertRowid);
}
}
const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(sourceTripId) as any[];
const accomMap = new Map<number, number | bigint>();
const insertAccom = db.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const a of oldAccom) {
const newPlaceId = placeMap.get(a.place_id);
const newStartDay = dayMap.get(a.start_day_id);
const newEndDay = dayMap.get(a.end_day_id);
if (newPlaceId && newStartDay && newEndDay) {
const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes);
accomMap.set(a.id, r.lastInsertRowid);
}
}
const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[];
const insertReservation = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time,
location, confirmation_number, notes, status, type, metadata, day_plan_position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const r of oldReservations) {
insertReservation.run(newTripId,
r.day_id ? (dayMap.get(r.day_id) ?? null) : null,
r.place_id ? (placeMap.get(r.place_id) ?? null) : null,
r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null,
r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null,
r.title, r.reservation_time, r.reservation_end_time,
r.location, r.confirmation_number, r.notes, r.status, r.type,
r.metadata, r.day_plan_position);
}
const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[];
const insertBudget = db.prepare(`
INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const b of oldBudget) {
insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order);
}
const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(sourceTripId) as any[];
const bagMap = new Map<number, number | bigint>();
const insertBag = db.prepare(`
INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order)
VALUES (?, ?, ?, ?, ?)
`);
for (const bag of oldBags) {
const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order);
bagMap.set(bag.id, r.lastInsertRowid);
}
const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(sourceTripId) as any[];
const insertPacking = db.prepare(`
INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id)
VALUES (?, ?, 0, ?, ?, ?, ?)
`);
for (const p of oldPacking) {
insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams,
p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null);
}
const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(sourceTripId) as any[];
const insertNote = db.prepare(`
INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const n of oldNotes) {
const newDayId = dayMap.get(n.day_id);
if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order);
}
return Number(newTripId);
});
return fn();
}
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
export function getTripSummary(tripId: number) {
@@ -448,6 +600,7 @@ export function getTripSummary(tripId: number) {
const budgetItems = listBudgetItems(tripId);
const budget = {
items: budgetItems,
item_count: budgetItems.length,
total: budgetItems.reduce((sum, i) => sum + (i.total_price || 0), 0),
currency: trip.currency,
@@ -455,6 +608,7 @@ export function getTripSummary(tripId: number) {
const packingItems = listPackingItems(tripId);
const packing = {
items: packingItems,
total: packingItems.length,
checked: (packingItems as { checked: number }[]).filter(i => i.checked).length,
};
+26
View File
@@ -321,6 +321,32 @@ export function createCollabNote(
return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote;
}
// ---------------------------------------------------------------------------
// Todo Items
// ---------------------------------------------------------------------------
export interface TestTodoItem {
id: number;
trip_id: number;
name: string;
checked: number;
category: string | null;
sort_order: number;
}
export function createTodoItem(
db: Database.Database,
tripId: number,
overrides: Partial<{ name: string; category: string; checked: number }> = {}
): TestTodoItem {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
).run(tripId, overrides.name ?? 'Test Todo', overrides.checked ?? 0, overrides.category ?? null, sortOrder);
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid) as TestTodoItem;
}
// ---------------------------------------------------------------------------
// Day Assignments
// ---------------------------------------------------------------------------
@@ -0,0 +1,244 @@
/**
* Unit tests for MCP extra assignment/reservation tools:
* move_assignment, get_assignment_participants, set_assignment_participants, reorder_reservations.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// move_assignment
// ---------------------------------------------------------------------------
describe('Tool: move_assignment', () => {
it('moves assignment to a different day and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day1.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'move_assignment',
arguments: { tripId: trip.id, assignmentId: assignment.id, newDayId: day2.id, oldDayId: day1.id, orderIndex: 0 },
});
const data = parseToolResult(result) as any;
expect(data.assignment).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:moved', expect.any(Object));
// Verify the assignment was moved
const updated = testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id) as any;
expect(updated.day_id).toBe(day2.id);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'move_assignment',
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'move_assignment',
arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_assignment_participants
// ---------------------------------------------------------------------------
describe('Tool: get_assignment_participants', () => {
it('returns empty participants array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_assignment_participants',
arguments: { tripId: trip.id, assignmentId: assignment.id },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.participants)).toBe(true);
expect(data.participants).toHaveLength(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_assignment_participants', arguments: { tripId: trip.id, assignmentId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// set_assignment_participants
// ---------------------------------------------------------------------------
describe('Tool: set_assignment_participants', () => {
it('sets participants and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.participants)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:participants', expect.any(Object));
});
});
it('empty array clears participants', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// First set
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.participants).toEqual([]);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
arguments: { tripId: trip.id, assignmentId: 1, userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// reorder_reservations
// ---------------------------------------------------------------------------
describe('Tool: reorder_reservations', () => {
it('returns success and broadcasts reservation:positions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const res1 = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' });
const res2 = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_reservations',
arguments: {
tripId: trip.id,
positions: [
{ id: res1.id, day_plan_position: 1 },
{ id: res2.id, day_plan_position: 0 },
],
},
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:positions', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_reservations',
arguments: { tripId: trip.id, positions: [{ id: 1, day_plan_position: 0 }] },
});
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,313 @@
/**
* Unit tests for MCP atlas expanded tools (atlas addon-gated):
* get_atlas_stats, list_visited_regions, mark_region_visited, unmark_region_visited,
* get_country_atlas_places, update_bucket_list_item.
* Also covers resources trek://atlas/stats and trek://atlas/regions.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// get_atlas_stats
// ---------------------------------------------------------------------------
describe('Tool: get_atlas_stats', () => {
it('returns stats object without error for empty data', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} });
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
expect(data.stats).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// list_visited_regions
// ---------------------------------------------------------------------------
describe('Tool: list_visited_regions', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions).toEqual([]);
});
});
it('returns regions after they have been inserted', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'FR-75', 'Paris', 'FR');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions).toHaveLength(1);
expect(data.regions[0].region_code).toBe('FR-75');
});
});
});
// ---------------------------------------------------------------------------
// mark_region_visited
// ---------------------------------------------------------------------------
describe('Tool: mark_region_visited', () => {
it('inserts region and returns region object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_region_visited',
arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' },
});
const data = parseToolResult(result) as any;
expect(data.region).toBeDefined();
expect(data.region.region_code).toBe('US-CA');
expect(data.region.region_name).toBe('California');
expect(data.region.country_code).toBe('US');
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
expect(row).toBeTruthy();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_region_visited',
arguments: { regionCode: 'DE-BY', regionName: 'Bavaria', countryCode: 'DE' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// unmark_region_visited
// ---------------------------------------------------------------------------
describe('Tool: unmark_region_visited', () => {
it('removes region and returns success', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'IT-LO', 'Lombardy', 'IT');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'unmark_region_visited',
arguments: { regionCode: 'IT-LO' },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO');
expect(row).toBeUndefined();
});
});
it('succeeds even when region was not marked (no-op)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'unmark_region_visited',
arguments: { regionCode: 'XX-YY' },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_country_atlas_places
// ---------------------------------------------------------------------------
describe('Tool: get_country_atlas_places', () => {
it('returns empty places array for a new user', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_country_atlas_places',
arguments: { countryCode: 'JP' },
});
const data = parseToolResult(result) as any;
expect(data.places).toBeDefined();
expect(Array.isArray(data.places)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_bucket_list_item
// ---------------------------------------------------------------------------
describe('Tool: update_bucket_list_item', () => {
it('updates notes and returns item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Visit Tokyo');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, notes: 'Cherry blossom season preferred' },
});
const data = parseToolResult(result) as any;
expect(data.item).toBeDefined();
expect(data.item.notes).toBe('Cherry blossom season preferred');
});
});
it('updates name of existing item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Old Name');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, name: 'New Name' },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('New Name');
});
});
it('returns isError for non-existent item', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId: 99999, notes: 'Will not work' },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Bucket Item');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_bucket_list_item',
arguments: { itemId, notes: 'blocked' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://atlas/stats
// ---------------------------------------------------------------------------
describe('Resource: trek://atlas/stats', () => {
it('returns stats object', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/stats' });
const data = parseResourceResult(result) as any;
expect(data).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://atlas/regions
// ---------------------------------------------------------------------------
describe('Resource: trek://atlas/regions', () => {
it('returns regions array', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
const data = parseResourceResult(result) as any;
expect(Array.isArray(data)).toBe(true);
});
});
it('returns inserted regions', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'ES-CT', 'Catalonia', 'ES');
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
const data = parseResourceResult(result) as any;
expect(data).toHaveLength(1);
expect(data[0].region_code).toBe('ES-CT');
});
});
});
@@ -0,0 +1,213 @@
/**
* Unit tests for MCP budget advanced tools:
* set_budget_item_members, toggle_budget_member_paid.
* Resources: trek://trips/{tripId}/budget/per-person, trek://trips/{tripId}/budget/settlement.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// set_budget_item_members
// ---------------------------------------------------------------------------
describe('Tool: set_budget_item_members', () => {
it('sets members and broadcasts budget:members-updated', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.item).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object));
});
});
it('empty array clears members', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id) VALUES (?, ?)').run(item.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.item).toBeDefined();
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any;
expect(remaining.cnt).toBe(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_budget_item_members',
arguments: { tripId: trip.id, itemId: item.id, userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_budget_member_paid
// ---------------------------------------------------------------------------
describe('Tool: toggle_budget_member_paid', () => {
it('flips paid flag and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 200 });
// Add member first
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_budget_member_paid',
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
});
const data = parseToolResult(result) as any;
expect(data.member).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_budget_member_paid',
arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Per-person resource
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/budget/per-person', () => {
it('returns array for trip with no items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
const data = JSON.parse(result.contents[0].text as string);
expect(Array.isArray(data)).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` });
const data = JSON.parse(result.contents[0].text as string);
expect(data.error).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// Settlement resource
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/budget/settlement', () => {
it('returns settlement object for trip with no items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` });
const data = JSON.parse(result.contents[0].text as string);
expect(data).toBeDefined();
expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true);
});
});
});
@@ -0,0 +1,500 @@
/**
* Unit tests for MCP collab polls and chat tools (collab addon-gated):
* list_collab_polls, create_collab_poll, vote_collab_poll, close_collab_poll,
* delete_collab_poll, list_collab_messages, send_collab_message,
* delete_collab_message, react_collab_message.
* Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_collab_polls
// ---------------------------------------------------------------------------
describe('Tool: list_collab_polls', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_collab_polls',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.polls)).toBe(true);
expect(data.polls).toHaveLength(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_collab_polls', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// create_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: create_collab_poll', () => {
it('inserts poll with votes structure and broadcasts', 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_collab_poll',
arguments: {
tripId: trip.id,
question: 'Where should we eat?',
options: ['Pizza', 'Sushi', 'Tacos'],
},
});
const data = parseToolResult(result) as any;
expect(data.poll).toBeDefined();
expect(data.poll.question).toBe('Where should we eat?');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:created', expect.any(Object));
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_collab_poll',
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_collab_poll',
arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// vote_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: vote_collab_poll', () => {
it('records vote and broadcasts collab:poll:voted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a poll directly in the DB
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'vote_collab_poll',
arguments: { tripId: trip.id, pollId: Number(pollId), optionIndex: 0 },
});
const data = parseToolResult(result) as any;
expect(data.poll).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:voted', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'vote_collab_poll',
arguments: { tripId: trip.id, pollId: 1, optionIndex: 0 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// close_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: close_collab_poll', () => {
it('sets closed flag and broadcasts collab:poll:closed', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'close_collab_poll',
arguments: { tripId: trip.id, pollId: Number(pollId) },
});
const data = parseToolResult(result) as any;
expect(data.poll).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:closed', expect.any(Object));
});
});
it('returns error for non-existent poll', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'close_collab_poll',
arguments: { tripId: trip.id, pollId: 99999 },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'close_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_collab_poll
// ---------------------------------------------------------------------------
describe('Tool: delete_collab_poll', () => {
it('removes poll and broadcasts collab:poll:deleted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_collab_poll',
arguments: { tripId: trip.id, pollId: Number(pollId) },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:deleted', expect.objectContaining({ pollId: Number(pollId) }));
expect(testDb.prepare('SELECT id FROM collab_polls WHERE id = ?').get(Number(pollId))).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_poll', arguments: { tripId: trip.id, pollId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_collab_messages
// ---------------------------------------------------------------------------
describe('Tool: list_collab_messages', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_collab_messages',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.messages)).toBe(true);
expect(data.messages).toHaveLength(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_collab_messages', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// send_collab_message
// ---------------------------------------------------------------------------
describe('Tool: send_collab_message', () => {
it('inserts message and broadcasts collab:message:created', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'send_collab_message',
arguments: { tripId: trip.id, text: 'Hello team!' },
});
const data = parseToolResult(result) as any;
expect(data.message).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:created', expect.any(Object));
});
});
it('sends message with replyTo when parent exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Original message') as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'send_collab_message',
arguments: { tripId: trip.id, text: 'Reply here', replyTo: Number(msgId) },
});
const data = parseToolResult(result) as any;
expect(data.message).toBeDefined();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'send_collab_message',
arguments: { tripId: trip.id, text: 'Hello!' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'send_collab_message', arguments: { tripId: trip.id, text: 'Hi' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_collab_message
// ---------------------------------------------------------------------------
describe('Tool: delete_collab_message', () => {
it('soft-deletes message and broadcasts collab:message:deleted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'To be deleted') as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_collab_message',
arguments: { tripId: trip.id, messageId: Number(msgId) },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:deleted', expect.any(Object));
});
});
it('returns error when message belongs to different user', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Add other as trip member
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Owner message') as any).lastInsertRowid;
await withHarness(other.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_collab_message',
arguments: { tripId: trip.id, messageId: Number(msgId) },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_message', arguments: { tripId: trip.id, messageId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// react_collab_message
// ---------------------------------------------------------------------------
describe('Tool: react_collab_message', () => {
it('toggles reaction and broadcasts collab:message:reacted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'React to me') as any).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'react_collab_message',
arguments: { tripId: trip.id, messageId: Number(msgId), emoji: '👍' },
});
const data = parseToolResult(result) as any;
expect(data.reactions).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:reacted', expect.any(Object));
});
});
it('returns error for non-existent message', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'react_collab_message',
arguments: { tripId: trip.id, messageId: 99999, emoji: '👍' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'react_collab_message', arguments: { tripId: trip.id, messageId: 1, emoji: '👍' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
describe('Resource: trek://trips/{tripId}/collab/polls', () => {
it('returns polls list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
const data = parseResourceResult(result) as any;
expect(Array.isArray(data)).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` });
const data = parseResourceResult(result) as any;
expect(data.error).toBeDefined();
});
});
});
describe('Resource: trek://trips/{tripId}/collab/messages', () => {
it('returns messages list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/messages` });
const data = parseResourceResult(result) as any;
expect(Array.isArray(data)).toBe(true);
});
});
});
@@ -0,0 +1,294 @@
/**
* Unit tests for MCP day and accommodation tools:
* create_day, delete_day,
* create_accommodation, update_accommodation, delete_accommodation.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// create_day
// ---------------------------------------------------------------------------
describe('Tool: create_day', () => {
it('creates a day with a date', 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_day',
arguments: { tripId: trip.id, date: '2025-06-15', notes: 'Arrival day' },
});
const data = parseToolResult(result) as any;
expect(data.day).toBeDefined();
expect(data.day.date).toBe('2025-06-15');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object));
});
});
it('creates a dateless day', 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_day',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.day).toBeDefined();
expect(data.day.date).toBeNull();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_day
// ---------------------------------------------------------------------------
describe('Tool: delete_day', () => {
it('deletes a day and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_day',
arguments: { tripId: trip.id, dayId: day.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', { id: day.id });
expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// create_accommodation
// ---------------------------------------------------------------------------
describe('Tool: create_accommodation', () => {
it('creates an accommodation and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Hotel du Louvre' });
const day1 = createDay(testDb, trip.id, { date: '2025-06-15' });
const day2 = createDay(testDb, trip.id, { date: '2025-06-17' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_accommodation',
arguments: {
tripId: trip.id,
place_id: place.id,
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '15:00',
check_out: '11:00',
confirmation: 'CONF123',
},
});
const data = parseToolResult(result) as any;
expect(data.accommodation).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const place = createPlace(testDb, trip.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_accommodation',
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_accommodation',
arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_accommodation
// ---------------------------------------------------------------------------
describe('Tool: update_accommodation', () => {
it('updates accommodation fields and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_accommodation',
arguments: { tripId: trip.id, accommodationId: acc.id, confirmation: 'NEW-CONF', check_in: '14:00' },
});
const data = parseToolResult(result) as any;
expect(data.accommodation).toBeDefined();
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object));
});
});
it('returns error for non-existent accommodation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_accommodation',
arguments: { tripId: trip.id, accommodationId: 99999, confirmation: 'X' },
});
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_accommodation',
arguments: { tripId: trip.id, accommodationId: 1, confirmation: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_accommodation
// ---------------------------------------------------------------------------
describe('Tool: delete_accommodation', () => {
it('deletes accommodation and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_accommodation',
arguments: { tripId: trip.id, accommodationId: acc.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ id: acc.id }));
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(acc.id)).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_accommodation', arguments: { tripId: trip.id, accommodationId: 1 } });
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,283 @@
/**
* Unit tests for MCP notification tools:
* list_notifications, get_unread_notification_count, mark_notification_read,
* mark_notification_unread, mark_all_notifications_read, delete_notification,
* delete_all_notifications.
* Also covers the resource trek://notifications/in-app.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
// ---------------------------------------------------------------------------
// Helper: insert a notification directly into the DB
// ---------------------------------------------------------------------------
function createNotification(db: any, userId: number, overrides: any = {}) {
const r = db.prepare(
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
VALUES (?, ?, ?, ?, ?, ?, 0)`
).run(
overrides.type ?? 'simple',
overrides.scope ?? 'user',
overrides.target ?? 0,
userId,
overrides.title_key ?? 'notification.test.title',
overrides.text_key ?? 'notification.test.body'
);
return db.prepare('SELECT * FROM notifications WHERE id = ?').get(r.lastInsertRowid);
}
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_notifications
// ---------------------------------------------------------------------------
describe('Tool: list_notifications', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.notifications).toEqual([]);
});
});
it('returns notifications when they exist', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id, { title_key: 'notif.first' });
createNotification(testDb, user.id, { title_key: 'notif.second' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.notifications).toHaveLength(2);
});
});
it('returns only unread notifications when unread_only is true', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
const read = createNotification(testDb, user.id) as any;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(read.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_notifications', arguments: { unread_only: true } });
const data = parseToolResult(result) as any;
expect(data.notifications).toHaveLength(1);
});
});
});
// ---------------------------------------------------------------------------
// get_unread_notification_count
// ---------------------------------------------------------------------------
describe('Tool: get_unread_notification_count', () => {
it('returns 0 initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.count).toBe(0);
});
});
it('returns 1 after inserting one unread notification', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.count).toBe(1);
});
});
});
// ---------------------------------------------------------------------------
// mark_notification_read
// ---------------------------------------------------------------------------
describe('Tool: mark_notification_read', () => {
it('flips is_read to 1 and returns success', async () => {
const { user } = createUser(testDb);
const notif = createNotification(testDb, user.id) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
arguments: { notificationId: notif.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
expect(row.is_read).toBe(1);
});
});
it('returns isError for non-existent notification', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
arguments: { notificationId: 99999 },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const notif = createNotification(testDb, user.id) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_read',
arguments: { notificationId: notif.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// mark_notification_unread
// ---------------------------------------------------------------------------
describe('Tool: mark_notification_unread', () => {
it('flips is_read to 0', async () => {
const { user } = createUser(testDb);
const notif = createNotification(testDb, user.id) as any;
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(notif.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_unread',
arguments: { notificationId: notif.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any;
expect(row.is_read).toBe(0);
});
});
it('returns isError for non-existent notification', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'mark_notification_unread',
arguments: { notificationId: 99999 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// mark_all_notifications_read
// ---------------------------------------------------------------------------
describe('Tool: mark_all_notifications_read', () => {
it('marks all notifications read and returns count', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id);
createNotification(testDb, user.id);
createNotification(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.count).toBe(3);
const unread = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0').get(user.id) as any).c;
expect(unread).toBe(0);
});
});
it('returns count 0 when nothing to mark', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.count).toBe(0);
});
});
});
// ---------------------------------------------------------------------------
// Resource: trek://notifications/in-app
// ---------------------------------------------------------------------------
describe('Resource: trek://notifications/in-app', () => {
it('returns notifications list', async () => {
const { user } = createUser(testDb);
createNotification(testDb, user.id, { title_key: 'notif.test' });
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
const data = parseResourceResult(result) as any;
expect(data.notifications).toBeDefined();
expect(Array.isArray(data.notifications)).toBe(true);
expect(data.notifications).toHaveLength(1);
});
});
it('returns empty notifications for user with none', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://notifications/in-app' });
const data = parseResourceResult(result) as any;
expect(data.notifications).toEqual([]);
});
});
});
@@ -0,0 +1,459 @@
/**
* Unit tests for MCP packing advanced tools:
* reorder_packing_items, list_packing_bags, create_packing_bag, update_packing_bag,
* delete_packing_bag, set_bag_members, get_packing_category_assignees,
* set_packing_category_assignees, apply_packing_template, save_packing_template,
* bulk_import_packing.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// reorder_packing_items
// ---------------------------------------------------------------------------
describe('Tool: reorder_packing_items', () => {
it('reorders packing items and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item1 = createPackingItem(testDb, trip.id, { name: 'Shirt' });
const item2 = createPackingItem(testDb, trip.id, { name: 'Pants' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_packing_items',
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:reordered', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_packing_items',
arguments: { tripId: trip.id, orderedIds: [item.id] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_packing_bags
// ---------------------------------------------------------------------------
describe('Tool: list_packing_bags', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_bags',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.bags).toEqual([]);
});
});
it('returns bags that exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#ff0000');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_bags',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.bags).toHaveLength(1);
expect(data.bags[0].name).toBe('Carry-on');
});
});
});
// ---------------------------------------------------------------------------
// create_packing_bag
// ---------------------------------------------------------------------------
describe('Tool: create_packing_bag', () => {
it('creates a bag and broadcasts', 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_packing_bag',
arguments: { tripId: trip.id, name: 'Checked bag', color: '#3b82f6' },
});
const data = parseToolResult(result) as any;
expect(data.bag).toBeDefined();
expect(data.bag.name).toBe('Checked bag');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_packing_bag',
arguments: { tripId: trip.id, name: 'Bag' },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_packing_bag',
arguments: { tripId: trip.id, name: 'Bag' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_packing_bag
// ---------------------------------------------------------------------------
describe('Tool: update_packing_bag', () => {
it('updates bag name and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Old Name', '#aabbcc');
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(r.lastInsertRowid) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_bag',
arguments: { tripId: trip.id, bagId: bag.id, name: 'New Name' },
});
const data = parseToolResult(result) as any;
expect(data.bag).toBeDefined();
expect(data.bag.name).toBe('New Name');
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-updated', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_packing_bag',
arguments: { tripId: trip.id, bagId: 1, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_packing_bag
// ---------------------------------------------------------------------------
describe('Tool: delete_packing_bag', () => {
it('deletes a bag and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Delete Me', '#000000');
const bagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_bag',
arguments: { tripId: trip.id, bagId },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-deleted', expect.any(Object));
expect(testDb.prepare('SELECT id FROM packing_bags WHERE id = ?').get(bagId)).toBeUndefined();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_packing_bag',
arguments: { tripId: trip.id, bagId: 1 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// set_bag_members
// ---------------------------------------------------------------------------
describe('Tool: set_bag_members', () => {
it('sets bag members and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_bag_members',
arguments: { tripId: trip.id, bagId, userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object));
});
});
it('clears bag members when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
testDb.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_bag_members',
arguments: { tripId: trip.id, bagId, userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_packing_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: get_packing_category_assignees', () => {
it('returns empty object initially', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_packing_category_assignees',
arguments: { tripId: trip.id },
});
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual({});
});
});
});
// ---------------------------------------------------------------------------
// set_packing_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: set_packing_category_assignees', () => {
it('sets category assignees and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object));
});
});
it('clears assignees when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Clothing', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Electronics', userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// apply_packing_template
// ---------------------------------------------------------------------------
describe('Tool: apply_packing_template', () => {
it('returns error for non-existent template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'apply_packing_template',
arguments: { tripId: trip.id, templateId: 99999 },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// save_packing_template
// ---------------------------------------------------------------------------
describe('Tool: save_packing_template', () => {
it('saves the current packing list as a template', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'Weekend Trip' },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'save_packing_template',
arguments: { tripId: trip.id, templateName: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// bulk_import_packing
// ---------------------------------------------------------------------------
describe('Tool: bulk_import_packing', () => {
it('imports multiple packing items and count matches', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const items = [
{ name: 'Passport', category: 'Documents' },
{ name: 'Charger', category: 'Electronics' },
{ name: 'Sunscreen', category: 'Toiletries' },
];
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: { tripId: trip.id, items },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.count).toBe(items.length);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_import_packing',
arguments: { tripId: trip.id, items: [{ name: 'Item' }] },
});
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,312 @@
/**
* Unit tests for MCP tag, maps extras, and weather tools:
* list_tags, create_tag, update_tag, delete_tag,
* get_place_details, reverse_geocode, resolve_maps_url,
* get_weather, get_detailed_weather.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/mapsService', () => ({
searchPlaces: vi.fn(),
getPlaceDetails: vi.fn().mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' }),
reverseGeocode: vi.fn().mockResolvedValue({ name: 'Paris', address: 'France' }),
resolveGoogleMapsUrl: vi.fn().mockResolvedValue({ lat: 48.8566, lng: 2.3522, name: 'Paris' }),
}));
vi.mock('../../../src/services/weatherService', () => ({
getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }),
getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import * as mapsService from '../../../src/services/mapsService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_tags
// ---------------------------------------------------------------------------
describe('Tool: list_tags', () => {
it('returns empty array initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.tags).toEqual([]);
});
});
it('returns only tags belonging to the current user', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'My Tag', '#ff0000');
testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(other.id, 'Other Tag', '#00ff00');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_tags', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.tags).toHaveLength(1);
expect(data.tags[0].name).toBe('My Tag');
});
});
});
// ---------------------------------------------------------------------------
// create_tag
// ---------------------------------------------------------------------------
describe('Tool: create_tag', () => {
it('creates a tag and returns the tag object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_tag',
arguments: { name: 'Adventure', color: '#ff5500' },
});
const data = parseToolResult(result) as any;
expect(data.tag).toBeDefined();
expect(data.tag.name).toBe('Adventure');
expect(data.tag.color).toBe('#ff5500');
expect(data.tag.user_id).toBe(user.id);
});
});
it('creates a tag with only a name', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_tag',
arguments: { name: 'Food' },
});
const data = parseToolResult(result) as any;
expect(data.tag.name).toBe('Food');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_tag',
arguments: { name: 'Blocked' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_tag
// ---------------------------------------------------------------------------
describe('Tool: update_tag', () => {
it('updates tag name and color', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Old Name', '#aaaaaa');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_tag',
arguments: { tagId, name: 'New Name', color: '#bbbbbb' },
});
const data = parseToolResult(result) as any;
expect(data.tag).toBeDefined();
expect(data.tag.name).toBe('New Name');
expect(data.tag.color).toBe('#bbbbbb');
});
});
it('returns isError for non-existent tagId', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_tag',
arguments: { tagId: 99999, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_tag
// ---------------------------------------------------------------------------
describe('Tool: delete_tag', () => {
it('removes the tag row', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'To Delete', '#cccccc');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_tag',
arguments: { tagId },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeUndefined();
});
});
});
// ---------------------------------------------------------------------------
// get_place_details
// ---------------------------------------------------------------------------
describe('Tool: get_place_details', () => {
it('returns details from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_place_details',
arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' },
});
const data = parseToolResult(result) as any;
expect(data.details).toBeDefined();
expect(data.details.name).toBe('Eiffel Tower');
});
});
it('returns isError when service returns null', async () => {
const { getPlaceDetails } = await import('../../../src/services/mapsService');
(getPlaceDetails as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_place_details',
arguments: { placeId: 'nonexistent-place-id' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// reverse_geocode
// ---------------------------------------------------------------------------
describe('Tool: reverse_geocode', () => {
it('returns result from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reverse_geocode',
arguments: { lat: 48.8566, lng: 2.3522 },
});
const data = parseToolResult(result) as any;
expect(data.name).toBe('Paris');
expect(data.address).toBe('France');
});
});
});
// ---------------------------------------------------------------------------
// resolve_maps_url
// ---------------------------------------------------------------------------
describe('Tool: resolve_maps_url', () => {
it('returns result from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'resolve_maps_url',
arguments: { url: 'https://maps.app.goo.gl/example' },
});
const data = parseToolResult(result) as any;
expect(data.lat).toBe(48.8566);
expect(data.lng).toBe(2.3522);
expect(data.name).toBe('Paris');
});
});
});
// ---------------------------------------------------------------------------
// get_weather
// ---------------------------------------------------------------------------
describe('Tool: get_weather', () => {
it('returns weather from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_weather',
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
});
const data = parseToolResult(result) as any;
expect(data.weather).toBeDefined();
expect(data.weather.temp).toBe(20);
expect(data.weather.condition).toBe('sunny');
});
});
});
// ---------------------------------------------------------------------------
// get_detailed_weather
// ---------------------------------------------------------------------------
describe('Tool: get_detailed_weather', () => {
it('returns detailed weather from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'get_detailed_weather',
arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' },
});
const data = parseToolResult(result) as any;
expect(data.weather).toBeDefined();
expect(Array.isArray(data.weather.hourly)).toBe(true);
});
});
});
+438
View File
@@ -0,0 +1,438 @@
/**
* Unit tests for MCP todo tools:
* create_todo, update_todo, toggle_todo, delete_todo, reorder_todos,
* list_todos, get_todo_category_assignees, set_todo_category_assignees.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createTodoItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_todos
// ---------------------------------------------------------------------------
describe('Tool: list_todos', () => {
it('returns empty list for a new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.items).toEqual([]);
});
});
it('returns todos ordered by sort_order', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
createTodoItem(testDb, trip.id, { name: 'First' });
createTodoItem(testDb, trip.id, { name: 'Second' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.items).toHaveLength(2);
expect(data.items[0].name).toBe('First');
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// create_todo
// ---------------------------------------------------------------------------
describe('Tool: create_todo', () => {
it('creates a todo item with all fields', 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_todo',
arguments: {
tripId: trip.id,
name: 'Book hotel',
category: 'Booking',
due_date: '2025-06-01',
description: 'Find a good deal',
priority: 2,
},
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Book hotel');
expect(data.item.category).toBe('Booking');
expect(data.item.due_date).toBe('2025-06-01');
expect(data.item.priority).toBe(2);
expect(data.item.checked).toBe(0);
});
});
it('creates a minimal todo item', 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_todo',
arguments: { tripId: trip.id, name: 'Pack bags' },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Pack bags');
expect(data.item.checked).toBe(0);
});
});
it('broadcasts todo:created event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'Test' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:created', expect.any(Object));
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_todo
// ---------------------------------------------------------------------------
describe('Tool: update_todo', () => {
it('updates todo name and category', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id, { name: 'Old name', category: 'General' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_todo',
arguments: { tripId: trip.id, itemId: item.id, name: 'New name', category: 'Booking' },
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('New name');
expect(data.item.category).toBe('Booking');
});
});
it('clears due_date when passed null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id);
const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_todo',
arguments: { tripId: trip.id, itemId: item.id, due_date: null },
});
const data = parseToolResult(result) as any;
expect(data.item.due_date).toBeNull();
});
});
it('broadcasts todo:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_todo
// ---------------------------------------------------------------------------
describe('Tool: toggle_todo', () => {
it('marks a todo as done', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_todo',
arguments: { tripId: trip.id, itemId: item.id, checked: true },
});
const data = parseToolResult(result) as any;
expect(data.item.checked).toBe(1);
});
});
it('unchecks a done todo', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id, { checked: 1 });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_todo',
arguments: { tripId: trip.id, itemId: item.id, checked: false },
});
const data = parseToolResult(result) as any;
expect(data.item.checked).toBe(0);
});
});
it('broadcasts todo:updated event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_todo
// ---------------------------------------------------------------------------
describe('Tool: delete_todo', () => {
it('deletes an existing todo item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM todo_items WHERE id = ?').get(item.id)).toBeUndefined();
});
});
it('broadcasts todo:deleted event', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:deleted', expect.any(Object));
});
});
it('returns error for item not found', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: 99999 } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// reorder_todos
// ---------------------------------------------------------------------------
describe('Tool: reorder_todos', () => {
it('reorders todo items', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const item1 = createTodoItem(testDb, trip.id, { name: 'First' });
const item2 = createTodoItem(testDb, trip.id, { name: 'Second' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'reorder_todos',
arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
// item2 should now have sort_order 0
const updated = testDb.prepare('SELECT sort_order FROM todo_items WHERE id = ?').get(item2.id) as any;
expect(updated.sort_order).toBe(0);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'reorder_todos', arguments: { tripId: trip.id, orderedIds: [1] } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_todo_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: get_todo_category_assignees', () => {
it('returns empty object for a new trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_todo_category_assignees', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual({});
});
});
});
// ---------------------------------------------------------------------------
// set_todo_category_assignees
// ---------------------------------------------------------------------------
describe('Tool: set_todo_category_assignees', () => {
it('sets category assignees and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [user.id] },
});
const data = parseToolResult(result) as any;
expect(Array.isArray(data.assignees)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:assignees', expect.any(Object));
});
});
it('clears assignees when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Set then clear
testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [] },
});
const data = parseToolResult(result) as any;
expect(data.assignees).toEqual([]);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
arguments: { tripId: trip.id, categoryName: 'Test', userIds: [] },
});
expect(result.isError).toBe(true);
});
});
});
@@ -0,0 +1,378 @@
/**
* Unit tests for MCP trip member, copy, ICS, and share-link tools:
* list_trip_members, add_trip_member, remove_trip_member,
* copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// list_trip_members
// ---------------------------------------------------------------------------
describe('Tool: list_trip_members', () => {
it('returns owner and empty members list for own trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.owner.id).toBe(user.id);
expect(data.owner.role).toBe('owner');
expect(Array.isArray(data.members)).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// add_trip_member
// ---------------------------------------------------------------------------
describe('Tool: add_trip_member', () => {
it('adds a member by username', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: member.username },
});
const data = parseToolResult(result) as any;
expect(data.member.username).toBe(member.username);
expect(data.member.role).toBe('member');
});
});
it('broadcasts member:added event', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: member.email },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object));
});
});
it('returns error when user not found', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: 'nonexistent@example.com' },
});
expect(result.isError).toBe(true);
});
});
it('returns error when non-owner tries to add', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const { user: outsider } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: outsider.username },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'add_trip_member',
arguments: { tripId: trip.id, identifier: 'someone@example.com' },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// remove_trip_member
// ---------------------------------------------------------------------------
describe('Tool: remove_trip_member', () => {
it('removes a member', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(owner.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: member.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
expect(row).toBeUndefined();
});
});
it('broadcasts member:removed event', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(owner.id, async (h) => {
await h.client.callTool({ name: 'remove_trip_member', arguments: { tripId: trip.id, memberId: member.id } });
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:removed', expect.any(Object));
});
});
it('returns error when non-owner tries to remove', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
await withHarness(member.id, async (h) => {
const result = await h.client.callTool({
name: 'remove_trip_member',
arguments: { tripId: trip.id, memberId: owner.id },
});
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// copy_trip
// ---------------------------------------------------------------------------
describe('Tool: copy_trip', () => {
it('duplicates a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original', start_date: '2025-01-01', end_date: '2025-01-03' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.trip).toBeTruthy();
// New trip should be a different row
const count = testDb.prepare('SELECT COUNT(*) as cnt FROM trips').get() as any;
expect(count.cnt).toBe(2);
});
});
it('uses custom title when provided', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Original' });
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id, title: 'My Copy' } });
const newTrip = testDb.prepare("SELECT * FROM trips WHERE title = 'My Copy'").get() as any;
expect(newTrip).toBeTruthy();
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// export_trip_ics
// ---------------------------------------------------------------------------
describe('Tool: export_trip_ics', () => {
it('returns ICS content for a trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2025-06-01', end_date: '2025-06-05' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.ics).toContain('BEGIN:VCALENDAR');
expect(data.ics).toContain('Paris Trip');
expect(data.filename).toMatch(/\.ics$/);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_share_link / create_share_link / delete_share_link
// ---------------------------------------------------------------------------
describe('Tool: get_share_link', () => {
it('returns null when no share link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.link).toBeNull();
});
});
it('returns share link info when it exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a share link directly
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'test-token-123', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.link.token).toBe('test-token-123');
expect(data.link.share_map).toBe(true);
});
});
});
describe('Tool: create_share_link', () => {
it('creates a new share link', 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_share_link',
arguments: { tripId: trip.id, share_map: true, share_bookings: false, share_packing: false },
});
const data = parseToolResult(result) as any;
expect(data.token).toBeTruthy();
expect(data.created).toBe(true);
});
});
it('updates existing share link permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'existing-token', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_share_link',
arguments: { tripId: trip.id, share_packing: true },
});
const data = parseToolResult(result) as any;
expect(data.created).toBe(false); // updated, not created
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_share_link', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});
describe('Tool: delete_share_link', () => {
it('revokes the share link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'to-delete', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(trip.id);
expect(row).toBeUndefined();
});
});
});
+14
View File
@@ -337,4 +337,18 @@ describe('Tool: get_trip_summary', () => {
expect(data.trip.title).toBe('Demo Trip');
});
});
it('includes todos, files, pollCount, messageCount in response', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Summary Test' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.todos)).toBe(true);
expect(Array.isArray(data.files)).toBe(true);
expect(typeof data.pollCount).toBe('number');
expect(typeof data.messageCount).toBe('number');
});
});
});
+477
View File
@@ -0,0 +1,477 @@
/**
* Unit tests for MCP vacay tools (vacay addon-gated):
* get_vacay_plan, update_vacay_plan, set_vacay_color,
* list_vacay_years, add_vacay_year, delete_vacay_year,
* get_vacay_entries, toggle_vacay_entry, toggle_company_holiday,
* get_vacay_stats, update_vacay_stats,
* add_holiday_calendar, update_holiday_calendar, delete_holiday_calendar,
* list_holiday_countries, list_holidays.
* Resources: trek://vacay/plan, trek://vacay/entries/{year}.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
}));
// Mock async service functions that make external calls
vi.mock('../../../src/services/vacayService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
updatePlan: vi.fn().mockResolvedValue(undefined),
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
}
// ---------------------------------------------------------------------------
// get_vacay_plan
// ---------------------------------------------------------------------------
describe('Tool: get_vacay_plan', () => {
it('returns plan data object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_vacay_plan', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.plan).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// update_vacay_plan
// ---------------------------------------------------------------------------
describe('Tool: update_vacay_plan', () => {
it('calls updatePlan and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_vacay_plan',
arguments: { block_weekends: true, holidays_enabled: false },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { block_weekends: true } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// set_vacay_color
// ---------------------------------------------------------------------------
describe('Tool: set_vacay_color', () => {
it('updates color and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_vacay_years
// ---------------------------------------------------------------------------
describe('Tool: list_vacay_years', () => {
it('returns years array', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_vacay_years', arguments: {} });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.years)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// add_vacay_year
// ---------------------------------------------------------------------------
describe('Tool: add_vacay_year', () => {
it('adds year to list', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.years)).toBe(true);
expect(data.years).toContain(2025);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_vacay_year
// ---------------------------------------------------------------------------
describe('Tool: delete_vacay_year', () => {
it('removes year from list', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
// Add year first
await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } });
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(Array.isArray(data.years)).toBe(true);
expect(data.years).not.toContain(2025);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_vacay_entries
// ---------------------------------------------------------------------------
describe('Tool: get_vacay_entries', () => {
it('returns entries array (empty initially)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_vacay_entries', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(data.entries).toBeDefined();
expect(Array.isArray(data.entries.entries)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_vacay_entry
// ---------------------------------------------------------------------------
describe('Tool: toggle_vacay_entry', () => {
it('toggles entry and returns action', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
const data = parseToolResult(result) as any;
expect(data.action).toBeDefined();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// toggle_company_holiday
// ---------------------------------------------------------------------------
describe('Tool: toggle_company_holiday', () => {
it('toggles company holiday and returns action', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_company_holiday',
arguments: { date: '2025-12-25', note: 'Christmas' },
});
const data = parseToolResult(result) as any;
expect(data.action).toBeDefined();
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-25' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// get_vacay_stats
// ---------------------------------------------------------------------------
describe('Tool: get_vacay_stats', () => {
it('returns stats object', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_vacay_stats', arguments: { year: 2025 } });
const data = parseToolResult(result) as any;
expect(data.stats).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// update_vacay_stats
// ---------------------------------------------------------------------------
describe('Tool: update_vacay_stats', () => {
it('updates vacation days allowance and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 25 } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 20 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// add_holiday_calendar
// ---------------------------------------------------------------------------
describe('Tool: add_holiday_calendar', () => {
it('inserts calendar row and returns calendar', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'add_holiday_calendar',
arguments: { region: 'US', label: 'US Holidays', color: '#ff0000' },
});
const data = parseToolResult(result) as any;
expect(data.calendar).toBeDefined();
expect(data.calendar.region).toBe('US');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'US' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// update_holiday_calendar
// ---------------------------------------------------------------------------
describe('Tool: update_holiday_calendar', () => {
it('updates label and color', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
// First add a calendar
const addResult = await h.client.callTool({
name: 'add_holiday_calendar',
arguments: { region: 'DE', label: 'Germany' },
});
const added = parseToolResult(addResult) as any;
const calId = added.calendar.id;
const result = await h.client.callTool({
name: 'update_holiday_calendar',
arguments: { calendarId: calId, label: 'German Holidays', color: '#00ff00' },
});
const data = parseToolResult(result) as any;
expect(data.calendar).toBeDefined();
expect(data.calendar.label).toBe('German Holidays');
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: 1, label: 'X' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_holiday_calendar
// ---------------------------------------------------------------------------
describe('Tool: delete_holiday_calendar', () => {
it('removes calendar and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const addResult = await h.client.callTool({
name: 'add_holiday_calendar',
arguments: { region: 'FR' },
});
const added = parseToolResult(addResult) as any;
const calId = added.calendar.id;
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: calId } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: 1 } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// list_holiday_countries
// ---------------------------------------------------------------------------
describe('Tool: list_holiday_countries', () => {
it('returns countries from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_holiday_countries', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.countries).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// list_holidays
// ---------------------------------------------------------------------------
describe('Tool: list_holidays', () => {
it('returns holidays from mocked service', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_holidays', arguments: { country: 'US', year: 2025 } });
const data = parseToolResult(result) as any;
expect(data.holidays).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// Resources
// ---------------------------------------------------------------------------
describe('Resource: trek://vacay/plan', () => {
it('returns plan data', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://vacay/plan' });
const data = parseResourceResult(result) as any;
expect(data).toBeDefined();
});
});
});
describe('Resource: trek://vacay/entries/{year}', () => {
it('returns entries for a year', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://vacay/entries/2025' });
const data = parseResourceResult(result) as any;
expect(data).toBeDefined();
expect(Array.isArray(data.entries)).toBe(true);
});
});
});