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
+5
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 || '{}') }));
+3
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: {
+6
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);
}
+35
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;
}
}
+2 -2
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;
+3
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: {
+108 -5
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 {