Files
TREK/server/src/mcp/tools/days.ts
T
2026-05-25 21:59:42 +02:00

393 lines
14 KiB
TypeScript

import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
createNote as createDayNote,
getNote as getDayNote,
updateNote as updateDayNote,
deleteNote as deleteDayNote,
dayExists as dayNoteExists,
} from '../../services/dayNoteService';
import {
getDay,
updateDay,
validateAccommodationRefs,
createDay,
deleteDay,
createAccommodation,
getAccommodation,
updateAccommodation,
deleteAccommodation,
} from '../../services/dayService';
import { createPlace } from '../../services/placeService';
import { canWrite } from '../scopes';
import {
safeBroadcast,
TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied,
noAccess,
ok,
} from './_shared';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
export function registerDayTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!canWrite(scopes, 'trips')) return;
// --- 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();
if (!getDay(dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
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(
'create_place_accommodation',
{
description:
'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
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")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
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(),
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({
tripId,
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
place_notes,
website,
phone,
start_day_id,
end_day_id,
check_in,
check_out,
confirmation,
accommodation_notes,
price,
currency,
}) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0)
return {
content: [{ type: 'text' as const, text: dayErrors.map((e) => e.message).join(', ') }],
isError: true,
};
try {
const run = db.transaction(() => {
const place = createPlace(String(tripId), {
name,
description,
lat,
lng,
address,
category_id,
google_place_id,
osm_id,
notes: place_notes,
website,
phone,
price,
currency,
});
const accommodation = createAccommodation(tripId, {
place_id: place.id,
start_day_id,
end_day_id,
check_in,
check_out,
confirmation,
notes: accommodation_notes,
});
return { place, accommodation };
});
const result = run();
safeBroadcast(tripId, 'place:created', { place: result.place });
safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation });
return ok(result);
} catch {
return {
content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }],
isError: true,
};
}
},
);
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();
if (!getAccommodation(accommodationId, tripId))
return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true };
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 });
},
);
}