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

194
MCP.md Normal file
View File

@@ -0,0 +1,194 @@
# MCP Integration
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a structured API.
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
## Table of Contents
- [Setup](#setup)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Screenshots](#screenshots)
---
## Setup
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp` endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
### 2. Create an API token
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
1. Click **Create New Token**
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
3. **Copy the token immediately** — it is shown only once and cannot be recovered
Each user can create up to **10 tokens**.
### 3. Configure your MCP client
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp",
"--header",
"Authorization: Bearer trek_your_token_here"
]
}
}
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
---
## Limitations & Important Notes
| Limitation | Details |
|---|---|
| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. |
| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. |
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 API tokens per user. |
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
---
## Resources (read-only)
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before making changes.
| Resource | URI | Description |
|---|---|---|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
---
## Tools (read-write)
TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a trip in a single call.
### Trip Summary
| Tool | Description |
|---|---|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. |
### Trips
| Tool | Description |
|---|---|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
### Places
| Tool | Description |
|---|---|
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
| `update_place` | Update any field of an existing place. |
| `delete_place` | Remove a place from a trip. |
### Day Planning
| Tool | Description |
|---|---|
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" "11:30"). |
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
### Reservations
| Tool | Description |
|---|---|
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
| Tool | Description |
|---|---|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
### Packing
| Tool | Description |
|---|---|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
### Day Notes
| Tool | Description |
|---|---|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### Collab Notes
| Tool | Description |
|---|---|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note and its associated files. |
### Bucket List
| Tool | Description |
|---|---|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
| `delete_bucket_list_item` | Remove an item from your bucket list. |
### Atlas
| Tool | Description |
|---|---|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
---
## Screenshots
<!-- Add screenshots of MCP in action here -->

View File

@@ -149,7 +149,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.title': 'إعداد MCP',
'settings.mcp.endpoint': 'نقطة نهاية MCP',
'settings.mcp.clientConfig': 'إعداد العميل',
'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه.',
'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
'settings.mcp.copy': 'نسخ',
'settings.mcp.copied': 'تم النسخ!',
'settings.mcp.apiTokens': 'رموز API',

View File

@@ -191,6 +191,31 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
'settings.mfa.demoBlocked': 'Indisponível no modo demonstração',
'settings.mcp.title': 'Configuração MCP',
'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuração do cliente',
'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': 'Copiado!',
'settings.mcp.apiTokens': 'Tokens de API',
'settings.mcp.createToken': 'Criar novo token',
'settings.mcp.noTokens': 'Nenhum token ainda. Crie um para conectar clientes MCP.',
'settings.mcp.tokenCreatedAt': 'Criado em',
'settings.mcp.tokenUsedAt': 'Usado em',
'settings.mcp.deleteTokenTitle': 'Excluir token',
'settings.mcp.deleteTokenMessage': 'Este token deixará de funcionar imediatamente. Qualquer cliente MCP que o utilize perderá o acesso.',
'settings.mcp.modal.createTitle': 'Criar token de API',
'settings.mcp.modal.tokenName': 'Nome do token',
'settings.mcp.modal.tokenNamePlaceholder': 'ex.: Claude Desktop, Notebook do trabalho',
'settings.mcp.modal.creating': 'Criando…',
'settings.mcp.modal.create': 'Criar token',
'settings.mcp.modal.createdTitle': 'Token criado',
'settings.mcp.modal.createdWarning': 'Este token será exibido apenas uma vez. Copie e guarde agora — não poderá ser recuperado.',
'settings.mcp.modal.done': 'Concluído',
'settings.mcp.toast.created': 'Token criado',
'settings.mcp.toast.createError': 'Falha ao criar token',
'settings.mcp.toast.deleted': 'Token excluído',
'settings.mcp.toast.deleteError': 'Falha ao excluir token',
// Login
'login.error': 'Falha no login. Verifique suas credenciais.',
@@ -388,6 +413,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
'admin.addons.catalog.collab.name': 'Colab',
'admin.addons.catalog.collab.description': 'Notas, enquetes e chat em tempo real para planejar a viagem',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol para integração com assistentes de IA',
'admin.addons.subtitleBefore': 'Ative ou desative recursos para personalizar sua ',
'admin.addons.subtitleAfter': ' experiência.',
'admin.addons.enabled': 'Ativado',

View File

@@ -144,7 +144,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.title': 'MCP-Konfiguration',
'settings.mcp.endpoint': 'MCP-Endpunkt',
'settings.mcp.clientConfig': 'Client-Konfiguration',
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten.',
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
'settings.mcp.copy': 'Kopieren',
'settings.mcp.copied': 'Kopiert!',
'settings.mcp.apiTokens': 'API-Tokens',

View File

@@ -144,7 +144,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.title': 'MCP Configuration',
'settings.mcp.endpoint': 'MCP Endpoint',
'settings.mcp.clientConfig': 'Client Configuration',
'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below.',
'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).',
'settings.mcp.copy': 'Copy',
'settings.mcp.copied': 'Copied!',
'settings.mcp.apiTokens': 'API Tokens',

View File

@@ -145,7 +145,7 @@ const es: Record<string, string> = {
'settings.mcp.title': 'Configuración MCP',
'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuración del cliente',
'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo.',
'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': '¡Copiado!',
'settings.mcp.apiTokens': 'Tokens de API',

View File

@@ -144,7 +144,7 @@ const fr: Record<string, string> = {
'settings.mcp.title': 'Configuration MCP',
'settings.mcp.endpoint': 'Point de terminaison MCP',
'settings.mcp.clientConfig': 'Configuration du client',
'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous.',
'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).',
'settings.mcp.copy': 'Copier',
'settings.mcp.copied': 'Copié !',
'settings.mcp.apiTokens': 'Tokens API',

View File

@@ -144,7 +144,7 @@ const nl: Record<string, string> = {
'settings.mcp.title': 'MCP-configuratie',
'settings.mcp.endpoint': 'MCP-eindpunt',
'settings.mcp.clientConfig': 'Clientconfiguratie',
'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst.',
'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).',
'settings.mcp.copy': 'Kopiëren',
'settings.mcp.copied': 'Gekopieerd!',
'settings.mcp.apiTokens': 'API-tokens',

View File

@@ -144,7 +144,7 @@ const ru: Record<string, string> = {
'settings.mcp.title': 'Настройка MCP',
'settings.mcp.endpoint': 'MCP-эндпоинт',
'settings.mcp.clientConfig': 'Конфигурация клиента',
'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже.',
'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).',
'settings.mcp.copy': 'Копировать',
'settings.mcp.copied': 'Скопировано!',
'settings.mcp.apiTokens': 'API-токены',

View File

@@ -144,7 +144,7 @@ const zh: Record<string, string> = {
'settings.mcp.title': 'MCP 配置',
'settings.mcp.endpoint': 'MCP 端点',
'settings.mcp.clientConfig': '客户端配置',
'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。',
'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd',
'settings.mcp.copy': '复制',
'settings.mcp.copied': '已复制!',
'settings.mcp.apiTokens': 'API 令牌',

View File

@@ -168,14 +168,19 @@ export default function SettingsPage(): React.ReactElement {
}
const mcpEndpoint = `${window.location.origin}/mcp`
const mcpJsonConfig = JSON.stringify({
mcpServers: {
trek: {
url: mcpEndpoint,
headers: { Authorization: 'Bearer <your_token>' }
}
const mcpJsonConfig = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--header",
"Authorization: Bearer <your_token>"
]
}
}, null, 2)
}
}`
// Map settings
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')

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,