some fixes

This commit is contained in:
jubnl
2026-03-30 06:59:24 +02:00
parent 7b2d45665c
commit 153b7f64b7
20 changed files with 659 additions and 65 deletions

View File

@@ -350,6 +350,11 @@ function runMigrations(db: Database.Database): void {
db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run();
} catch {}
},
// Migration 48: Make mcp_tokens.token_hash unique
() => db.exec(`
DROP INDEX IF EXISTS idx_mcp_tokens_hash;
CREATE UNIQUE INDEX idx_mcp_tokens_hash ON mcp_tokens(token_hash)
`),
];
if (currentVersion < migrations.length) {

View File

@@ -162,7 +162,9 @@ app.use('/api/backup', backupRoutes);
// MCP endpoint (Streamable HTTP transport, per-user auth)
import { mcpHandler, closeMcpSessions } from './mcp';
app.all('/mcp', mcpHandler);
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
app.delete('/mcp', mcpHandler);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {

View File

@@ -10,6 +10,7 @@ import { registerResources } from './resources';
import { registerTools } from './tools';
interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: number;
lastActivity: number;
@@ -18,15 +19,49 @@ interface McpSession {
const sessions = new Map<string, McpSession>();
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_SESSIONS_PER_USER = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX = 60; // requests per minute per user
interface RateLimitEntry {
count: number;
windowStart: number;
}
const rateLimitMap = new Map<number, RateLimitEntry>();
function isRateLimited(userId: number): boolean {
const now = Date.now();
const entry = rateLimitMap.get(userId);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(userId, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT_MAX;
}
function countSessionsForUser(userId: number): number {
const cutoff = Date.now() - SESSION_TTL_MS;
let count = 0;
for (const session of sessions.values()) {
if (session.userId === userId && session.lastActivity >= cutoff) count++;
}
return count;
}
const sessionSweepInterval = setInterval(() => {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [sid, session] of sessions) {
if (session.lastActivity < cutoff) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
for (const [uid, entry] of rateLimitMap) {
if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid);
}
}, 10 * 60 * 1000); // sweep every 10 minutes
// Prevent the interval from keeping the process alive if nothing else is running
@@ -78,6 +113,11 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
return;
}
if (isRateLimited(user.id)) {
res.status(429).json({ error: 'Too many requests. Please slow down.' });
return;
}
const sessionId = req.headers['mcp-session-id'] as string | undefined;
// Resume an existing session
@@ -102,6 +142,11 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
return;
}
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
res.status(429).json({ error: 'Session limit reached. Close an existing session before opening a new one.' });
return;
}
// Create a new per-user MCP server and session
const server = new McpServer({ name: 'trek', version: '1.0.0' });
registerResources(server, user.id);
@@ -110,7 +155,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { transport, userId: user.id, lastActivity: Date.now() });
sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
},
onsessionclosed: (sid) => {
sessions.delete(sid);
@@ -121,11 +166,24 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
await transport.handleRequest(req, res, req.body);
}
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
}
/** Close all active MCP sessions (call during graceful shutdown). */
export function closeMcpSessions(): void {
clearInterval(sessionSweepInterval);
for (const [, session] of sessions) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
}
sessions.clear();
rateLimitMap.clear();
}

View File

@@ -12,6 +12,11 @@ const TRIP_SELECT = `
JOIN users u ON u.id = t.user_id
`;
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : null;
}
function accessDenied(uri: string) {
return {
contents: [{
@@ -55,8 +60,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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
@@ -72,8 +77,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
{ description: 'Days of a trip with their assigned places' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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'
@@ -113,8 +118,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
{ description: 'All places/POIs saved in a trip' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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
@@ -132,8 +137,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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);
@@ -147,8 +152,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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);
@@ -162,8 +167,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
{ description: 'Reservations (flights, hotels, restaurants) for a trip' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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
@@ -182,9 +187,9 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
{ description: 'Notes for a specific day in a trip' },
async (uri, { tripId, dayId }) => {
const tId = Number(tripId);
const dId = Number(dayId);
if (!canAccessTrip(tId, userId)) return accessDenied(uri.href);
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);
@@ -198,8 +203,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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
@@ -220,8 +225,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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;
@@ -245,8 +250,8 @@ export function registerResources(server: McpServer, userId: number): void {
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip' },
async (uri, { tripId }) => {
const id = Number(tripId);
if (!canAccessTrip(id, userId)) return accessDenied(uri.href);
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

View File

@@ -1,5 +1,7 @@
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 { broadcast } from '../websocket';
@@ -60,14 +62,27 @@ export function registerTools(server: McpServer, userId: number): void {
},
async ({ title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
if (start_date && end_date && new Date(end_date) < new Date(start_date)) {
return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true };
}
const 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);
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);
})();
return ok({ trip });
}
);
@@ -88,6 +103,16 @@ export function registerTools(server: McpServer, userId: number): void {
async ({ tripId, title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (start_date) {
const d = new Date(start_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date)
return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true };
}
if (end_date) {
const d = new Date(end_date + 'T00:00:00Z');
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
const 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(
@@ -122,6 +147,31 @@ export function registerTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
'list_trips',
{
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
},
},
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);
return ok({ trips });
}
);
// --- PLACES ---
server.registerTool(
@@ -397,27 +447,66 @@ export function registerTools(server: McpServer, userId: number): void {
server.registerTool(
'create_reservation',
{
description: 'Add a reservation (flight, hotel, restaurant, etc.) to a trip.',
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.',
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
type: z.enum(['flight', 'hotel', 'restaurant', 'activity', 'other']),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', '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(),
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
},
},
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id }) => {
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, assignment_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);
// 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 };
}
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, confirmation) VALUES (?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, 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);
})();
if (type === 'hotel' && place_id && start_day_id && end_day_id) {
broadcast(tripId, 'accommodation:created', {});
}
broadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
@@ -435,16 +524,111 @@ export function registerTools(server: McpServer, userId: number): void {
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);
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 });
}
broadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true });
}
);
server.registerTool(
'link_hotel_accommodation',
{
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
place_id: z.number().int().positive().describe('The hotel place to link'),
start_day_id: z.number().int().positive().describe('Check-in day ID'),
end_day_id: z.number().int().positive().describe('Check-out day ID'),
},
},
async ({ tripId, reservationId, place_id, start_day_id, end_day_id }) => {
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 };
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 (!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 (!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 };
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 = ? WHERE id = ?')
.run(place_id, start_day_id, end_day_id, accommodationId);
} else {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, confirmation) VALUES (?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, 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);
})();
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 });
}
);
// --- DAYS ---
server.registerTool(
'update_assignment_time',
{
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
},
},
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 });
}
);
server.registerTool(
'update_day',
{
@@ -472,28 +656,41 @@ export function registerTools(server: McpServer, userId: number): void {
server.registerTool(
'update_reservation',
{
description: 'Update an existing reservation in a trip.',
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
type: z.enum(['flight', 'hotel', 'restaurant', 'activity', 'other']).optional(),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', '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(),
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
},
},
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status }) => {
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;
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 };
}
db.prepare(`
UPDATE reservations SET
title = ?, type = ?, reservation_time = ?, location = ?,
confirmation_number = ?, notes = ?, status = ?
confirmation_number = ?, notes = ?, status = ?,
place_id = ?, assignment_id = ?
WHERE id = ?
`).run(
title ?? existing.title,
@@ -503,6 +700,8 @@ export function registerTools(server: McpServer, userId: number): void {
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);
@@ -590,7 +789,7 @@ export function registerTools(server: McpServer, userId: number): void {
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'),
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
},
},
async ({ tripId, dayId, assignmentIds }) => {
@@ -613,7 +812,7 @@ export function registerTools(server: McpServer, userId: number): void {
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.',
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as a context loader before planning or modifying a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
@@ -654,7 +853,24 @@ export function registerTools(server: McpServer, userId: number): void {
assignmentsByDay[a.day_id].push(a);
}
}
const daysWithAssignments = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] }));
// 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(`
@@ -688,6 +904,11 @@ export function registerTools(server: McpServer, userId: number): void {
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 },
@@ -696,6 +917,7 @@ export function registerTools(server: McpServer, userId: number): void {
budget: { ...budgetStats, currency: trip.currency },
packing: packingStats,
reservations,
collab_notes: collabNotes,
});
}
);
@@ -800,6 +1022,78 @@ export function registerTools(server: McpServer, userId: number): void {
}
);
server.registerTool(
'update_collab_note',
{
description: 'Edit an existing collaborative note on a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
content: z.string().max(10000).optional(),
category: z.string().max(100).optional(),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().describe('Pin the note to the top'),
},
},
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);
broadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
}
);
server.registerTool(
'delete_collab_note',
{
description: 'Delete a collaborative note from a trip.',
inputSchema: {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
},
},
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);
})();
broadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true });
}
);
// --- DAY NOTES ---
server.registerTool(

View File

@@ -7,6 +7,7 @@ import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { revokeUserSessions } from '../mcp';
const router = express.Router();
@@ -422,9 +423,10 @@ router.get('/mcp-tokens', (req: Request, res: Response) => {
});
router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(req.params.id);
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(req.params.id) as { id: number; user_id: number } | undefined;
if (!token) return res.status(404).json({ error: 'Token not found' });
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(req.params.id);
revokeUserSessions(token.user_id);
res.json({ success: true });
});

View File

@@ -13,6 +13,7 @@ import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
import { randomBytes, createHash } from 'crypto';
import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
authenticator.options = { window: 1 };
@@ -720,6 +721,7 @@ router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req
const authReq = req as AuthRequest;
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Token name is required' });
if (name.trim().length > 100) return res.status(400).json({ error: 'Token name must be 100 characters or less' });
const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(authReq.user.id) as { count: number }).count;
if (tokenCount >= 10) return res.status(400).json({ error: 'Maximum of 10 tokens per user reached' });
@@ -745,6 +747,7 @@ router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) =>
const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(id, authReq.user.id);
if (!token) return res.status(404).json({ error: 'Token not found' });
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
revokeUserSessions(authReq.user.id);
res.json({ success: true });
});

View File

@@ -85,7 +85,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
// Get all file_links for this trip's files
const fileIds = files.map(f => f.id);
let linksMap: Record<number, number[]> = {};
let linksMap: Record<number, { file_id: number; reservation_id: number | null; place_id: number | null }[]> = {};
if (fileIds.length > 0) {
const placeholders = fileIds.map(() => '?').join(',');
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as { file_id: number; reservation_id: number | null; place_id: number | null }[];

View File

@@ -9,7 +9,6 @@
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,