mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
feat: mcp server
This commit is contained in:
@@ -0,0 +1,881 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { db, canAccessTrip, isOwner } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
|
||||
const MS_PER_DAY = 86400000;
|
||||
const MAX_TRIP_DAYS = 90;
|
||||
|
||||
function isDemoUser(userId: number): boolean {
|
||||
if (process.env.DEMO_MODE !== 'true') return false;
|
||||
const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
return user?.email === 'demo@nomad.app';
|
||||
}
|
||||
|
||||
function demoDenied() {
|
||||
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
|
||||
}
|
||||
|
||||
function noAccess() {
|
||||
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
|
||||
}
|
||||
|
||||
function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
/** Create days for a newly created trip (fresh insert, no existing days). */
|
||||
function createDaysForNewTrip(tripId: number | bigint, startDate: string | null, endDate: string | null): void {
|
||||
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
|
||||
if (startDate && endDate) {
|
||||
const [sy, sm, sd] = startDate.split('-').map(Number);
|
||||
const [ey, em, ed] = endDate.split('-').map(Number);
|
||||
const startMs = Date.UTC(sy, sm - 1, sd);
|
||||
const endMs = Date.UTC(ey, em - 1, ed);
|
||||
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
|
||||
for (let i = 0; i < numDays; i++) {
|
||||
const d = new Date(startMs + i * MS_PER_DAY);
|
||||
const date = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
||||
insert.run(tripId, i + 1, date);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < 7; i++) insert.run(tripId, i + 1, null);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTools(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)'),
|
||||
},
|
||||
},
|
||||
async ({ title, description, start_date, end_date, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
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 result = db.prepare(
|
||||
'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(userId, title, description || null, start_date || null, end_date || null, currency || 'EUR');
|
||||
createDaysForNewTrip(result.lastInsertRowid as number, start_date || null, end_date || null);
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(result.lastInsertRowid);
|
||||
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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> & { title: string; description: string; start_date: string; end_date: string; currency: string } | undefined;
|
||||
if (!existing) return noAccess();
|
||||
db.prepare(
|
||||
'UPDATE trips SET title = ?, description = ?, start_date = ?, end_date = ?, currency = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
).run(
|
||||
title ?? existing.title,
|
||||
description !== undefined ? description : existing.description,
|
||||
start_date !== undefined ? start_date : existing.start_date,
|
||||
end_date !== undefined ? end_date : existing.end_date,
|
||||
currency ?? existing.currency,
|
||||
tripId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId);
|
||||
broadcast(tripId, 'trip:updated', { trip: updated });
|
||||
return ok({ trip: updated });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_trip',
|
||||
{
|
||||
description: 'Delete a trip. Only the trip owner can delete it.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!isOwner(tripId, userId)) return noAccess();
|
||||
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
|
||||
return ok({ success: true, tripId });
|
||||
}
|
||||
);
|
||||
|
||||
// --- PLACES ---
|
||||
|
||||
server.registerTool(
|
||||
'create_place',
|
||||
{
|
||||
description: 'Add a new place/POI to a trip.',
|
||||
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(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, notes, website, phone }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, notes, website, phone, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, name, description || null, lat ?? null, lng ?? null, address || null, category_id || null, notes || null, website || null, phone || null, 'walking');
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
|
||||
broadcast(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(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
db.prepare(`
|
||||
UPDATE places SET
|
||||
name = ?, description = ?, lat = ?, lng = ?, address = ?, notes = ?, website = ?, phone = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? existing.name,
|
||||
description !== undefined ? description : existing.description,
|
||||
lat !== undefined ? lat : existing.lat,
|
||||
lng !== undefined ? lng : existing.lng,
|
||||
address !== undefined ? address : existing.address,
|
||||
notes !== undefined ? notes : existing.notes,
|
||||
website !== undefined ? website : existing.website,
|
||||
phone !== undefined ? phone : existing.phone,
|
||||
placeId
|
||||
);
|
||||
const place = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId);
|
||||
broadcast(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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, placeId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
|
||||
broadcast(tripId, 'place:deleted', { placeId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, placeId, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
|
||||
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
|
||||
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
|
||||
).run(dayId, placeId, orderIndex, notes || null);
|
||||
const assignment = db.prepare(`
|
||||
SELECT da.*, p.name as place_name, p.address, p.lat, p.lng
|
||||
FROM day_assignments da JOIN places p ON da.place_id = p.id
|
||||
WHERE da.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
broadcast(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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, assignmentId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const assignment = db.prepare(
|
||||
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
|
||||
).get(assignmentId, dayId, tripId);
|
||||
if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignmentId);
|
||||
broadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_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 budget_items (trip_id, category, name, total_price, note, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, category || 'Other', name, total_price, note || null, sortOrder);
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
broadcast(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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(itemId);
|
||||
broadcast(tripId, 'budget:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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)'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_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 packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, name, 0, category || 'General', sortOrder);
|
||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
broadcast(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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId, checked }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
db.prepare('UPDATE packing_items SET checked = ? WHERE id = ?').run(checked ? 1 : 0, itemId);
|
||||
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId);
|
||||
broadcast(tripId, 'packing:updated', { item: updated });
|
||||
return ok({ item: updated });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_packing_item',
|
||||
{
|
||||
description: 'Remove an item from the packing checklist.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM packing_items WHERE id = ?').run(itemId);
|
||||
broadcast(tripId, 'packing:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- RESERVATIONS ---
|
||||
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Add a reservation (flight, hotel, restaurant, etc.) to a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'activity', '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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, title, type, reservation_time || null, location || null, confirmation_number || null, notes || null, day_id || null, place_id || null, 'confirmed');
|
||||
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
|
||||
broadcast(tripId, 'reservation:created', { 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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const res = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId);
|
||||
if (!res) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId);
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, title }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
db.prepare('UPDATE days SET title = ? WHERE id = ?').run(title, dayId);
|
||||
const updated = db.prepare('SELECT * FROM days WHERE id = ?').get(dayId);
|
||||
broadcast(tripId, 'day:updated', { day: updated });
|
||||
return ok({ day: updated });
|
||||
}
|
||||
);
|
||||
|
||||
// --- RESERVATIONS (update) ---
|
||||
|
||||
server.registerTool(
|
||||
'update_reservation',
|
||||
{
|
||||
description: 'Update an existing reservation in a trip.',
|
||||
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', 'activity', 'other']).optional(),
|
||||
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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
||||
db.prepare(`
|
||||
UPDATE reservations SET
|
||||
title = ?, type = ?, reservation_time = ?, location = ?,
|
||||
confirmation_number = ?, notes = ?, status = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title ?? existing.title,
|
||||
type ?? existing.type,
|
||||
reservation_time !== undefined ? reservation_time : existing.reservation_time,
|
||||
location !== undefined ? location : existing.location,
|
||||
confirmation_number !== undefined ? confirmation_number : existing.confirmation_number,
|
||||
notes !== undefined ? notes : existing.notes,
|
||||
status ?? existing.status,
|
||||
reservationId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId);
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updated });
|
||||
return ok({ reservation: updated });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
|
||||
db.prepare(`
|
||||
UPDATE budget_items SET
|
||||
name = ?, category = ?, total_price = ?, persons = ?, days = ?, note = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? existing.name,
|
||||
category ?? existing.category,
|
||||
total_price !== undefined ? total_price : existing.total_price,
|
||||
persons !== undefined ? persons : existing.persons,
|
||||
days !== undefined ? days : existing.days,
|
||||
note !== undefined ? note : existing.note,
|
||||
itemId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId);
|
||||
broadcast(tripId, 'budget:updated', { item: updated });
|
||||
return ok({ item: updated });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, itemId, name, category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
||||
db.prepare('UPDATE packing_items SET name = ?, category = ? WHERE id = ?').run(
|
||||
name ?? existing.name,
|
||||
category ?? existing.category,
|
||||
itemId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId);
|
||||
broadcast(tripId, 'packing:updated', { item: updated });
|
||||
return ok({ item: updated });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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).describe('Assignment IDs in desired display order'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, assignmentIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
||||
const updateMany = db.transaction((ids: number[]) => {
|
||||
ids.forEach((id, index) => update.run(index, id, dayId));
|
||||
});
|
||||
updateMany(assignmentIds);
|
||||
broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
|
||||
return ok({ success: true, dayId, order: assignmentIds });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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, accommodations, budget totals, packing stats, and upcoming reservations. Use this as a context loader before planning or modifying a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ tripId }) => {
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined;
|
||||
if (!trip) return noAccess();
|
||||
|
||||
// Members
|
||||
const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id as number);
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.username, u.avatar, tm.added_at
|
||||
FROM trip_members tm JOIN users u ON tm.user_id = u.id
|
||||
WHERE tm.trip_id = ?
|
||||
`).all(tripId);
|
||||
|
||||
// Days with assignments
|
||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as (Record<string, unknown> & { id: number })[];
|
||||
const dayIds = days.map(d => d.id);
|
||||
const assignmentsByDay: Record<number, unknown[]> = {};
|
||||
if (dayIds.length > 0) {
|
||||
const placeholders = dayIds.map(() => '?').join(',');
|
||||
const assignments = db.prepare(`
|
||||
SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
|
||||
p.id as place_id, p.name, p.address, p.lat, p.lng,
|
||||
COALESCE(da.assignment_time, p.place_time) as place_time,
|
||||
c.name as category_name, c.icon as category_icon
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${placeholders})
|
||||
ORDER BY da.order_index ASC
|
||||
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
|
||||
for (const a of assignments) {
|
||||
if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = [];
|
||||
assignmentsByDay[a.day_id].push(a);
|
||||
}
|
||||
}
|
||||
const daysWithAssignments = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] }));
|
||||
|
||||
// Accommodations
|
||||
const accommodations = db.prepare(`
|
||||
SELECT da.*, p.name as place_name, ds.day_number as start_day_number, de.day_number as end_day_number
|
||||
FROM day_accommodations da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN days ds ON da.start_day_id = ds.id
|
||||
LEFT JOIN days de ON da.end_day_id = de.id
|
||||
WHERE da.trip_id = ?
|
||||
ORDER BY ds.day_number ASC
|
||||
`).all(tripId);
|
||||
|
||||
// Budget summary
|
||||
const budgetStats = db.prepare(`
|
||||
SELECT COUNT(*) as item_count, COALESCE(SUM(total_price), 0) as total
|
||||
FROM budget_items WHERE trip_id = ?
|
||||
`).get(tripId) as { item_count: number; total: number };
|
||||
|
||||
// Packing summary
|
||||
const packingStats = db.prepare(`
|
||||
SELECT COUNT(*) as total, SUM(CASE WHEN checked = 1 THEN 1 ELSE 0 END) as checked
|
||||
FROM packing_items WHERE trip_id = ?
|
||||
`).get(tripId) as { total: number; checked: number };
|
||||
|
||||
// Upcoming reservations (all, sorted by time)
|
||||
const reservations = db.prepare(`
|
||||
SELECT r.*, d.day_number
|
||||
FROM reservations r
|
||||
LEFT JOIN days d ON r.day_id = d.id
|
||||
WHERE r.trip_id = ?
|
||||
ORDER BY r.reservation_time ASC, r.created_at ASC
|
||||
`).all(tripId);
|
||||
|
||||
return ok({
|
||||
trip,
|
||||
members: { owner, collaborators: members },
|
||||
days: daysWithAssignments,
|
||||
accommodations,
|
||||
budget: { ...budgetStats, currency: trip.currency },
|
||||
packing: packingStats,
|
||||
reservations,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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(),
|
||||
},
|
||||
},
|
||||
async ({ name, lat, lng, country_code, notes }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const result = db.prepare(
|
||||
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(userId, name, lat ?? null, lng ?? null, country_code || null, notes || null);
|
||||
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
|
||||
return ok({ item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_bucket_list_item',
|
||||
{
|
||||
description: 'Remove an item from your travel bucket list.',
|
||||
inputSchema: {
|
||||
itemId: z.number().int().positive(),
|
||||
},
|
||||
},
|
||||
async ({ itemId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const item = db.prepare('SELECT id FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
|
||||
if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId);
|
||||
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")'),
|
||||
},
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(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'),
|
||||
},
|
||||
},
|
||||
async ({ country_code }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, country_code.toUpperCase());
|
||||
return ok({ success: true, country_code: country_code.toUpperCase() });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, title, content, category, color }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(tripId, userId, title, content || null, category || 'General', color || '#6366f1');
|
||||
const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid);
|
||||
broadcast(tripId, 'collab:note:created', { note });
|
||||
return ok({ note });
|
||||
}
|
||||
);
|
||||
|
||||
// --- 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'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
||||
if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(dayId, tripId, text.trim(), time || null, icon || '📝', 9999);
|
||||
const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
|
||||
broadcast(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'),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, noteId, text, time, icon }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const existing = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId) as Record<string, unknown> | undefined;
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
db.prepare('UPDATE day_notes SET text = ?, time = ?, icon = ? WHERE id = ?').run(
|
||||
text !== undefined ? text.trim() : existing.text,
|
||||
time !== undefined ? time : existing.time,
|
||||
icon ?? existing.icon,
|
||||
noteId
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(noteId);
|
||||
broadcast(tripId, 'dayNote:updated', { dayId, note: updated });
|
||||
return ok({ note: updated });
|
||||
}
|
||||
);
|
||||
|
||||
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(),
|
||||
},
|
||||
},
|
||||
async ({ tripId, dayId, noteId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId);
|
||||
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
|
||||
db.prepare('DELETE FROM day_notes WHERE id = ?').run(noteId);
|
||||
broadcast(tripId, 'dayNote:deleted', { noteId, dayId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user