refactor(mcp): replace direct DB access with service layer calls

Replace all db.prepare() calls in mcp/index.ts, mcp/resources.ts, and
mcp/tools.ts with calls to the service layer. Add missing service functions:
- authService: isDemoUser, verifyMcpToken, verifyJwtToken
- adminService: isAddonEnabled
- atlasService: listVisitedCountries
- tripService: getTripSummary, listTrips with null archived param

Also fix getAssignmentWithPlace and formatAssignmentWithPlace to expose
place_id, assignment_time, and assignment_end_time at the top level, and
fix updateDay to correctly handle null title for clearing.

Add comprehensive unit and integration test suite for the MCP layer (821 tests all passing).
This commit is contained in:
jubnl
2026-04-04 18:12:14 +02:00
parent 1ea0eb9965
commit 1bddb3c588
24 changed files with 4006 additions and 613 deletions

View File

@@ -1,11 +1,10 @@
import { Request, Response } from 'express';
import { randomUUID, createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { randomUUID } from 'crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { JWT_SECRET } from '../config';
import { db } from '../db/database';
import { User } from '../types';
import { verifyMcpToken, verifyJwtToken } from '../services/authService';
import { isAddonEnabled } from '../services/adminService';
import { registerResources } from './resources';
import { registerTools } from './tools';
@@ -74,36 +73,15 @@ function verifyToken(authHeader: string | undefined): User | null {
// Long-lived MCP API token (trek_...)
if (token.startsWith('trek_')) {
const hash = createHash('sha256').update(token).digest('hex');
const row = db.prepare(`
SELECT u.id, u.username, u.email, u.role
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ?
`).get(hash) as User | undefined;
if (row) {
// Update last_used_at (fire-and-forget, non-blocking)
db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash);
return row;
}
return null;
return verifyMcpToken(token);
}
// Short-lived JWT
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
return user || null;
} catch {
return null;
}
return verifyJwtToken(token);
}
export async function mcpHandler(req: Request, res: Response): Promise<void> {
const mcpAddon = db.prepare("SELECT enabled FROM addons WHERE id = 'mcp'").get() as { enabled: number } | undefined;
if (!mcpAddon || !mcpAddon.enabled) {
if (!isAddonEnabled('mcp')) {
res.status(403).json({ error: 'MCP is not enabled' });
return;
}

View File

@@ -1,16 +1,15 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import { db, canAccessTrip } from '../db/database';
const TRIP_SELECT = `
SELECT t.*,
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
u.username as owner_username,
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
FROM trips t
JOIN users u ON u.id = t.user_id
`;
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 { listReservations } from '../services/reservationService';
import { listNotes as listDayNotes } from '../services/dayNoteService';
import { listNotes as listCollabNotes } from '../services/collabService';
import { listCategories } from '../services/categoryService';
import { listBucketList, listVisitedCountries } from '../services/atlasService';
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
@@ -44,12 +43,7 @@ export function registerResources(server: McpServer, userId: number): void {
'trek://trips',
{ description: 'All trips the user owns or is a member of' },
async (uri) => {
const trips = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0
ORDER BY t.created_at DESC
`).all({ userId });
const trips = listTrips(userId, 0);
return jsonContent(uri.href, trips);
}
);
@@ -62,11 +56,7 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId: id });
const trip = getTrip(id, userId);
return jsonContent(uri.href, trip);
}
);
@@ -80,35 +70,8 @@ export function registerResources(server: McpServer, userId: number): void {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const days = db.prepare(
'SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC'
).all(id) as { id: number; day_number: number; date: string | null; title: string | null; notes: string | null }[];
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, p.category_id,
COALESCE(da.assignment_time, p.place_time) as place_time,
c.name as category_name, c.color as category_color, 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, da.created_at 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 result = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] }));
return jsonContent(uri.href, result);
const { days } = listDays(id);
return jsonContent(uri.href, days);
}
);
@@ -120,13 +83,7 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const places = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ?
ORDER BY p.created_at DESC
`).all(id);
const places = listPlaces(String(id), {});
return jsonContent(uri.href, places);
}
);
@@ -139,9 +96,7 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(id);
const items = listBudgetItems(id);
return jsonContent(uri.href, items);
}
);
@@ -154,9 +109,7 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(id);
const items = listPackingItems(id);
return jsonContent(uri.href, items);
}
);
@@ -169,14 +122,7 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(id);
const reservations = listReservations(id);
return jsonContent(uri.href, reservations);
}
);
@@ -190,9 +136,7 @@ export function registerResources(server: McpServer, userId: number): void {
const tId = parseId(tripId);
const dId = parseId(dayId);
if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href);
const notes = db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(dId, tId);
const notes = listDayNotes(dId, tId);
return jsonContent(uri.href, notes);
}
);
@@ -205,16 +149,7 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const accommodations = db.prepare(`
SELECT da.*, p.name as place_name, p.address as place_address, p.lat, p.lng,
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(id);
const accommodations = listAccommodations(id);
return jsonContent(uri.href, accommodations);
}
);
@@ -227,20 +162,10 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(id) as { user_id: number } | undefined;
if (!trip) return accessDenied(uri.href);
const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id) as Record<string, unknown> | undefined;
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 = ?
ORDER BY tm.added_at ASC
`).all(id);
return jsonContent(uri.href, {
owner: owner ? { ...owner, role: 'owner' } : null,
members,
});
const ownerRow = getTripOwner(id);
if (!ownerRow) return accessDenied(uri.href);
const { owner, members } = listMembers(id, ownerRow.user_id);
return jsonContent(uri.href, { owner, members });
}
);
@@ -252,13 +177,7 @@ export function registerResources(server: McpServer, userId: number): void {
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const notes = db.prepare(`
SELECT cn.*, u.username
FROM collab_notes cn
JOIN users u ON cn.user_id = u.id
WHERE cn.trip_id = ?
ORDER BY cn.pinned DESC, cn.updated_at DESC
`).all(id);
const notes = listCollabNotes(id);
return jsonContent(uri.href, notes);
}
);
@@ -269,9 +188,7 @@ export function registerResources(server: McpServer, userId: number): void {
'trek://categories',
{ description: 'All available place categories (id, name, color, icon) for use when creating places' },
async (uri) => {
const categories = db.prepare(
'SELECT id, name, color, icon FROM categories ORDER BY name ASC'
).all();
const categories = listCategories();
return jsonContent(uri.href, categories);
}
);
@@ -282,9 +199,7 @@ export function registerResources(server: McpServer, userId: number): void {
'trek://bucket-list',
{ description: 'Your personal travel bucket list' },
async (uri) => {
const items = db.prepare(
'SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC'
).all(userId);
const items = listBucketList(userId);
return jsonContent(uri.href, items);
}
);
@@ -295,9 +210,7 @@ export function registerResources(server: McpServer, userId: number): void {
'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas' },
async (uri) => {
const countries = db.prepare(
'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
).all(userId);
const countries = listVisitedCountries(userId);
return jsonContent(uri.href, countries);
}
);

View File

@@ -1,18 +1,29 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import path from 'path';
import fs from 'fs';
import { db, canAccessTrip, isOwner } from '../db/database';
import { canAccessTrip } from '../db/database';
import { broadcast } from '../websocket';
import { isDemoUser } from '../services/authService';
import {
listTrips, createTrip, updateTrip, deleteTrip, getTripSummary,
isOwner, verifyTripAccess,
} from '../services/tripService';
import { listPlaces, createPlace, updatePlace, deletePlace } from '../services/placeService';
import { listCategories } from '../services/categoryService';
import {
dayExists, placeExists, createAssignment, assignmentExistsInDay,
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
} from '../services/assignmentService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem } from '../services/packingService';
import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService';
import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService';
import { createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists } from '../services/dayNoteService';
import { createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote } from '../services/collabService';
import {
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
} from '../services/atlasService';
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';
}
const MAX_MCP_TRIP_DAYS = 90;
function demoDenied() {
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
@@ -26,25 +37,6 @@ 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 ---
@@ -75,14 +67,7 @@ export function registerTools(server: McpServer, userId: number): void {
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 = db.transaction(() => {
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');
const tripId = result.lastInsertRowid as number;
createDaysForNewTrip(tripId, start_date || null, end_date || null);
return db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId);
})();
const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS);
return ok({ trip });
}
);
@@ -113,21 +98,9 @@ export function registerTools(server: McpServer, userId: number): void {
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 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 });
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
broadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
);
@@ -142,7 +115,7 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!isOwner(tripId, userId)) return noAccess();
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
deleteTrip(tripId, userId, 'user');
return ok({ success: true, tripId });
}
);
@@ -156,18 +129,7 @@ export function registerTools(server: McpServer, userId: number): void {
},
},
async ({ include_archived }) => {
const trips = db.prepare(`
SELECT t.*, u.username as owner_username,
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
CASE WHEN t.user_id = ? THEN 1 ELSE 0 END as is_owner
FROM trips t
JOIN users u ON u.id = t.user_id
LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id IS NOT NULL)
AND (? = 1 OR t.is_archived = 0)
ORDER BY t.updated_at DESC
`).all(userId, userId, userId, include_archived ? 1 : 0);
const trips = listTrips(userId, include_archived ? null : 0);
return ok({ trips });
}
);
@@ -196,11 +158,7 @@ export function registerTools(server: McpServer, userId: number): void {
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 result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(tripId, name, description || null, lat ?? null, lng ?? null, address || null, category_id || null, google_place_id || null, osm_id || null, notes || null, website || null, phone || null, 'walking');
const place = db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
broadcast(tripId, 'place:created', { place });
return ok({ place });
}
@@ -226,25 +184,8 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, notes, website, phone });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
broadcast(tripId, 'place:updated', { place });
return ok({ place });
}
@@ -262,9 +203,8 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
broadcast(tripId, 'place:deleted', { placeId });
return ok({ success: true });
}
@@ -279,7 +219,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {},
},
async () => {
const categories = db.prepare('SELECT id, name, color, icon FROM categories ORDER BY name ASC').all();
const categories = listCategories();
return ok({ categories });
}
);
@@ -333,20 +273,9 @@ export function registerTools(server: McpServer, userId: number): void {
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);
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);
broadcast(tripId, 'assignment:created', { assignment });
return ok({ assignment });
}
@@ -365,11 +294,9 @@ export function registerTools(server: McpServer, userId: number): void {
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);
if (!assignmentExistsInDay(assignmentId, dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
deleteAssignment(assignmentId);
broadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
return ok({ success: true });
}
@@ -392,12 +319,7 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const item = createBudgetItem(tripId, { category, name, total_price, note });
broadcast(tripId, 'budget:created', { item });
return ok({ item });
}
@@ -415,9 +337,8 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const deleted = deleteBudgetItem(itemId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
broadcast(tripId, 'budget:deleted', { itemId });
return ok({ success: true });
}
@@ -438,12 +359,7 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const item = createPackingItem(tripId, { name, category: category || 'General' });
broadcast(tripId, 'packing:created', { item });
return ok({ item });
}
@@ -462,12 +378,10 @@ export function registerTools(server: McpServer, userId: number): void {
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);
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 };
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 });
broadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
@@ -483,9 +397,8 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const deleted = deletePackingItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
broadcast(tripId, 'packing:deleted', { itemId });
return ok({ success: true });
}
@@ -519,43 +432,28 @@ export function registerTools(server: McpServer, userId: number): void {
if (!canAccessTrip(tripId, userId)) return noAccess();
// Validate that all referenced IDs belong to this trip
if (day_id) {
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(day_id, tripId))
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
}
if (place_id) {
if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
}
if (start_day_id) {
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(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) {
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(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) {
if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId))
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
}
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 reservation = db.transaction(() => {
let accommodationId: number | null = null;
if (type === 'hotel' && place_id && start_day_id && end_day_id) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation_number || null);
accommodationId = accResult.lastInsertRowid as number;
}
const result = db.prepare(`
INSERT INTO reservations (trip_id, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, accommodation_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(tripId, title, type, reservation_time || null, location || null, confirmation_number || null, notes || null, day_id || null, place_id || null, assignment_id || null, accommodationId, 'pending');
return db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid);
})();
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;
if (type === 'hotel' && place_id && start_day_id && end_day_id) {
const { reservation, accommodationCreated } = createReservation(tripId, {
title, type, reservation_time, location, confirmation_number,
notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation,
});
if (accommodationCreated) {
broadcast(tripId, 'accommodation:created', {});
}
broadcast(tripId, 'reservation:created', { reservation });
@@ -575,16 +473,10 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId, reservationId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as { id: number; accommodation_id: number | null } | undefined;
if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
db.transaction(() => {
if (reservation.accommodation_id) {
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId);
})();
if (reservation.accommodation_id) {
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id });
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (accommodationDeleted) {
broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
}
broadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true });
@@ -608,36 +500,28 @@ export function registerTools(server: McpServer, userId: number): void {
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 reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record<string, unknown> | undefined;
if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (reservation.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
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 (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
if (!placeExists(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId))
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 (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId))
if (!getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
let accommodationId = reservation.accommodation_id as number | null;
const isNewAccommodation = !accommodationId;
db.transaction(() => {
if (accommodationId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
.run(place_id, start_day_id, end_day_id, check_in || null, check_out || null, accommodationId);
} else {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, reservation.confirmation_number || null);
accommodationId = accResult.lastInsertRowid as number;
}
db.prepare('UPDATE reservations SET place_id = ?, accommodation_id = ? WHERE id = ?')
.run(place_id, accommodationId, reservationId);
})();
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);
broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId);
broadcast(tripId, 'reservation:updated', { reservation: updated });
return ok({ reservation: updated, accommodation_id: accommodationId });
broadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
}
);
@@ -657,28 +541,15 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId, assignmentId, place_time, end_time }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignment = db.prepare(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`).get(assignmentId, tripId) as Record<string, unknown> | undefined;
if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(
place_time !== undefined ? place_time : assignment.assignment_time,
end_time !== undefined ? end_time : assignment.assignment_end_time,
assignmentId
);
const updated = db.prepare(`
SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
da.assignment_time, da.assignment_end_time,
p.id as place_id, p.name, p.address
FROM day_assignments da
JOIN places p ON da.place_id = p.id
WHERE da.id = ?
`).get(assignmentId);
broadcast(tripId, 'assignment:updated', { assignment: updated });
return ok({ assignment: updated });
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
);
broadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
);
@@ -695,10 +566,9 @@ export function registerTools(server: McpServer, userId: number): void {
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);
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 } : {});
broadcast(tripId, 'day:updated', { day: updated });
return ok({ day: updated });
}
@@ -727,39 +597,21 @@ export function registerTools(server: McpServer, userId: number): void {
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 = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record<string, unknown> | undefined;
const existing = getReservation(reservationId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (place_id != null) {
if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId))
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
}
if (assignment_id != null) {
if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId))
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], 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 };
db.prepare(`
UPDATE reservations SET
title = ?, type = ?, reservation_time = ?, location = ?,
confirmation_number = ?, notes = ?, status = ?,
place_id = ?, assignment_id = ?
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,
place_id !== undefined ? place_id : existing.place_id,
assignment_id !== undefined ? assignment_id : existing.assignment_id,
reservationId
);
const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId);
broadcast(tripId, 'reservation:updated', { reservation: updated });
return ok({ reservation: updated });
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);
broadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation });
}
);
@@ -783,24 +635,10 @@ export function registerTools(server: McpServer, userId: number): void {
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 });
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 };
broadcast(tripId, 'budget:updated', { item });
return ok({ item });
}
);
@@ -820,16 +658,11 @@ export function registerTools(server: McpServer, userId: number): void {
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 });
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 };
broadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
@@ -848,13 +681,8 @@ export function registerTools(server: McpServer, userId: number): void {
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);
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
reorderAssignments(dayId, assignmentIds);
broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
return ok({ success: true, dayId, order: assignmentIds });
}
@@ -872,106 +700,9 @@ export function registerTools(server: McpServer, userId: number): void {
},
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);
}
}
// Day notes
const dayNotesByDay: Record<number, unknown[]> = {};
if (dayIds.length > 0) {
const placeholders = dayIds.map(() => '?').join(',');
const dayNotes = db.prepare(`
SELECT * FROM day_notes WHERE day_id IN (${placeholders}) ORDER BY sort_order ASC
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
for (const n of dayNotes) {
if (!dayNotesByDay[n.day_id]) dayNotesByDay[n.day_id] = [];
dayNotesByDay[n.day_id].push(n);
}
}
const daysWithAssignments = days.map(d => ({
...d,
assignments: assignmentsByDay[d.id] || [],
notes: dayNotesByDay[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);
// Collab notes
const collabNotes = db.prepare(
'SELECT * FROM collab_notes WHERE trip_id = ? ORDER BY pinned DESC, updated_at DESC'
).all(tripId);
return ok({
trip,
members: { owner, collaborators: members },
days: daysWithAssignments,
accommodations,
budget: { ...budgetStats, currency: trip.currency },
packing: packingStats,
reservations,
collab_notes: collabNotes,
});
const summary = getTripSummary(tripId);
if (!summary) return noAccess();
return ok(summary);
}
);
@@ -991,10 +722,7 @@ export function registerTools(server: McpServer, userId: number): void {
},
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);
const item = createBucketItem(userId, { name, lat, lng, country_code, notes });
return ok({ item });
}
);
@@ -1009,9 +737,8 @@ export function registerTools(server: McpServer, userId: number): void {
},
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);
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 });
}
);
@@ -1028,7 +755,7 @@ export function registerTools(server: McpServer, userId: number): void {
},
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());
markCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
);
@@ -1043,7 +770,7 @@ export function registerTools(server: McpServer, userId: number): void {
},
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());
unmarkCountryVisited(userId, country_code.toUpperCase());
return ok({ success: true, country_code: country_code.toUpperCase() });
}
);
@@ -1065,11 +792,7 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const note = createCollabNote(tripId, userId, { title, content, category, color });
broadcast(tripId, 'collab:note:created', { note });
return ok({ note });
}
@@ -1092,26 +815,8 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
db.prepare(`
UPDATE collab_notes SET
title = CASE WHEN ? THEN ? ELSE title END,
content = CASE WHEN ? THEN ? ELSE content END,
category = CASE WHEN ? THEN ? ELSE category END,
color = CASE WHEN ? THEN ? ELSE color END,
pinned = CASE WHEN ? THEN ? ELSE pinned END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
title !== undefined ? 1 : 0, title !== undefined ? title : null,
content !== undefined ? 1 : 0, content !== undefined ? content : null,
category !== undefined ? 1 : 0, category !== undefined ? category : null,
color !== undefined ? 1 : 0, color !== undefined ? color : null,
pinned !== undefined ? 1 : 0, pinned !== undefined ? (pinned ? 1 : 0) : null,
noteId
);
const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(noteId);
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
broadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
}
@@ -1129,19 +834,8 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
const noteFiles = db.prepare('SELECT filename FROM trip_files WHERE note_id = ?').all(noteId) as { filename: string }[];
const uploadsDir = path.resolve(__dirname, '../../uploads');
for (const f of noteFiles) {
const resolved = path.resolve(path.join(uploadsDir, 'files', f.filename));
if (!resolved.startsWith(uploadsDir)) continue;
try { fs.unlinkSync(resolved); } catch {}
}
db.transaction(() => {
db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId);
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(noteId);
})();
const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
broadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true });
}
@@ -1164,12 +858,8 @@ export function registerTools(server: McpServer, userId: number): void {
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);
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = createDayNote(dayId, tripId, text, time, icon);
broadcast(tripId, 'dayNote:created', { dayId, note });
return ok({ note });
}
@@ -1191,17 +881,11 @@ export function registerTools(server: McpServer, userId: number): void {
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;
const existing = getDayNote(noteId, dayId, tripId);
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 });
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
broadcast(tripId, 'dayNote:updated', { dayId, note });
return ok({ note });
}
);
@@ -1218,9 +902,9 @@ export function registerTools(server: McpServer, userId: number): void {
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);
const note = getDayNote(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);
deleteDayNote(noteId);
broadcast(tripId, 'dayNote:deleted', { noteId, dayId });
return ok({ success: true });
}

View File

@@ -463,6 +463,11 @@ export function deleteTemplateItem(itemId: string) {
// ── Addons ─────────────────────────────────────────────────────────────────
export function isAddonEnabled(addonId: string): boolean {
const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as { enabled: number } | undefined;
return !!addon?.enabled;
}
export function listAddons() {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') }));

View File

@@ -35,8 +35,11 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
return {
id: a.id,
day_id: a.day_id,
place_id: a.place_id,
order_index: a.order_index,
notes: a.notes,
assignment_time: a.assignment_time ?? null,
assignment_end_time: a.assignment_end_time ?? null,
participants,
created_at: a.created_at,
place: {

View File

@@ -327,6 +327,12 @@ export function getCountryPlaces(userId: number, code: string) {
// ── Mark / unmark country ───────────────────────────────────────────────────
export function listVisitedCountries(userId: number): { country_code: string; created_at: string }[] {
return db.prepare(
'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as { country_code: string; created_at: string }[];
}
export function markCountryVisited(userId: number, code: string): void {
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
}

View File

@@ -988,3 +988,38 @@ export function createResourceToken(userId: number, purpose?: string): { error?:
if (!token) return { error: 'Service unavailable', status: 503 };
return { token };
}
// ---------------------------------------------------------------------------
// MCP auth helpers
// ---------------------------------------------------------------------------
export 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';
}
export function verifyMcpToken(rawToken: string): User | null {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = db.prepare(`
SELECT u.id, u.username, u.email, u.role
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ?
`).get(hash) as User | undefined;
if (row) {
db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash);
return row;
}
return null;
}
export function verifyJwtToken(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(decoded.id) as User | undefined;
return user || null;
} catch {
return null;
}
}

View File

@@ -145,10 +145,10 @@ export function getDay(id: string | number, tripId: string | number) {
return db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
}
export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string }) {
export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(
fields.notes || null,
fields.title !== undefined ? fields.title : current.title,
'title' in fields ? (fields.title ?? null) : current.title,
id
);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;

View File

@@ -56,8 +56,11 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
return {
id: a.id,
day_id: a.day_id,
place_id: a.place_id,
order_index: a.order_index,
notes: a.notes,
assignment_time: a.assignment_time ?? null,
assignment_end_time: a.assignment_end_time ?? null,
participants: participants || [],
created_at: a.created_at,
place: {

View File

@@ -27,7 +27,7 @@ export { isOwner };
// ── Day generation ────────────────────────────────────────────────────────
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null) {
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number) {
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
if (!startDate || !endDate) {
@@ -56,7 +56,7 @@ export function generateDays(tripId: number | bigint | string, startDate: string
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);
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS);
const targetDates: string[] = [];
for (let i = 0; i < numDays; i++) {
@@ -99,7 +99,15 @@ export function generateDays(tripId: number | bigint | string, startDate: string
// ── Trip CRUD ─────────────────────────────────────────────────────────────
export function listTrips(userId: number, archived: number) {
export function listTrips(userId: number, archived: number | null) {
if (archived === null) {
return db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
ORDER BY t.created_at DESC
`).all({ userId });
}
return db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
@@ -117,7 +125,7 @@ interface CreateTripData {
reminder_days?: number;
}
export function createTrip(userId: number, data: CreateTripData) {
export function createTrip(userId: number, data: CreateTripData, maxDays?: number) {
const rd = data.reminder_days !== undefined
? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3)
: 3;
@@ -128,7 +136,7 @@ export function createTrip(userId: number, data: CreateTripData) {
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
const tripId = result.lastInsertRowid;
generateDays(tripId, data.start_date || null, data.end_date || null);
generateDays(tripId, data.start_date || null, data.end_date || null, maxDays);
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
return { trip, tripId: Number(tripId), reminderDays: rd };
@@ -398,6 +406,101 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
return { ics, filename: `${safeFilename}.ics` };
}
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
export function getTripSummary(tripId: number) {
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined;
if (!trip) return null;
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);
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[]> = {};
const dayNotesByDay: 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 dayNotes = db.prepare(`
SELECT * FROM day_notes WHERE day_id IN (${placeholders}) ORDER BY sort_order ASC
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
for (const n of dayNotes) {
if (!dayNotesByDay[n.day_id]) dayNotesByDay[n.day_id] = [];
dayNotesByDay[n.day_id].push(n);
}
}
const daysWithAssignments = days.map(d => ({
...d,
assignments: assignmentsByDay[d.id] || [],
notes: dayNotesByDay[d.id] || [],
}));
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);
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 };
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 };
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);
const collabNotes = db.prepare(
'SELECT * FROM collab_notes WHERE trip_id = ? ORDER BY pinned DESC, updated_at DESC'
).all(tripId);
return {
trip,
members: { owner, collaborators: members },
days: daysWithAssignments,
accommodations,
budget: { ...budgetStats, currency: trip.currency },
packing: packingStats,
reservations,
collab_notes: collabNotes,
};
}
// ── Custom error types ────────────────────────────────────────────────────
export class NotFoundError extends Error {