From 153b7f64b7fb235662d60407b236dad45d3fc71e Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 30 Mar 2026 06:59:24 +0200 Subject: [PATCH] some fixes --- MCP.md | 194 ++++++++++++++++ client/src/i18n/translations/ar.ts | 2 +- client/src/i18n/translations/br.ts | 27 +++ client/src/i18n/translations/de.ts | 2 +- client/src/i18n/translations/en.ts | 2 +- client/src/i18n/translations/es.ts | 2 +- client/src/i18n/translations/fr.ts | 2 +- client/src/i18n/translations/nl.ts | 2 +- client/src/i18n/translations/ru.ts | 2 +- client/src/i18n/translations/zh.ts | 2 +- client/src/pages/SettingsPage.tsx | 19 +- server/src/db/migrations.ts | 5 + server/src/index.ts | 4 +- server/src/mcp/index.ts | 60 ++++- server/src/mcp/resources.ts | 47 ++-- server/src/mcp/tools.ts | 342 +++++++++++++++++++++++++++-- server/src/routes/admin.ts | 4 +- server/src/routes/auth.ts | 3 + server/src/routes/files.ts | 2 +- server/tsconfig.json | 1 - 20 files changed, 659 insertions(+), 65 deletions(-) create mode 100644 MCP.md diff --git a/MCP.md b/MCP.md new file mode 100644 index 0000000..2c0e3fe --- /dev/null +++ b/MCP.md @@ -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 + + diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index ac17862..6e61583 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -149,7 +149,7 @@ const ar: Record = { 'settings.mcp.title': 'إعداد MCP', 'settings.mcp.endpoint': 'نقطة نهاية MCP', 'settings.mcp.clientConfig': 'إعداد العميل', - 'settings.mcp.clientConfigHint': 'استبدل برمز API من القائمة أدناه.', + 'settings.mcp.clientConfigHint': 'استبدل برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).', 'settings.mcp.copy': 'نسخ', 'settings.mcp.copied': 'تم النسخ!', 'settings.mcp.apiTokens': 'رموز API', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 969e8a0..d16f5ab 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -191,6 +191,31 @@ const br: Record = { '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 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 = { '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', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 62aa1b9..34134f8 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -144,7 +144,7 @@ const de: Record = { 'settings.mcp.title': 'MCP-Konfiguration', 'settings.mcp.endpoint': 'MCP-Endpunkt', 'settings.mcp.clientConfig': 'Client-Konfiguration', - 'settings.mcp.clientConfigHint': 'Ersetze durch ein API-Token aus der Liste unten.', + 'settings.mcp.clientConfigHint': 'Ersetze 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', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 125662d..15bf445 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -144,7 +144,7 @@ const en: Record = { 'settings.mcp.title': 'MCP Configuration', 'settings.mcp.endpoint': 'MCP Endpoint', 'settings.mcp.clientConfig': 'Client Configuration', - 'settings.mcp.clientConfigHint': 'Replace with an API token from the list below.', + 'settings.mcp.clientConfigHint': 'Replace 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', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index e72f359..967ad3e 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -145,7 +145,7 @@ const es: Record = { 'settings.mcp.title': 'Configuración MCP', 'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.clientConfig': 'Configuración del cliente', - 'settings.mcp.clientConfigHint': 'Reemplaza con un token de la lista de abajo.', + 'settings.mcp.clientConfigHint': 'Reemplaza 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', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index cb530c8..bc21421 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -144,7 +144,7 @@ const fr: Record = { 'settings.mcp.title': 'Configuration MCP', 'settings.mcp.endpoint': 'Point de terminaison MCP', 'settings.mcp.clientConfig': 'Configuration du client', - 'settings.mcp.clientConfigHint': 'Remplacez par un token API de la liste ci-dessous.', + 'settings.mcp.clientConfigHint': 'Remplacez 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', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 0aad706..d949a9e 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -144,7 +144,7 @@ const nl: Record = { 'settings.mcp.title': 'MCP-configuratie', 'settings.mcp.endpoint': 'MCP-eindpunt', 'settings.mcp.clientConfig': 'Clientconfiguratie', - 'settings.mcp.clientConfigHint': 'Vervang door een API-token uit de onderstaande lijst.', + 'settings.mcp.clientConfigHint': 'Vervang 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', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 9a29040..c0b529b 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -144,7 +144,7 @@ const ru: Record = { 'settings.mcp.title': 'Настройка MCP', 'settings.mcp.endpoint': 'MCP-эндпоинт', 'settings.mcp.clientConfig': 'Конфигурация клиента', - 'settings.mcp.clientConfigHint': 'Замените на API-токен из списка ниже.', + 'settings.mcp.clientConfigHint': 'Замените на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).', 'settings.mcp.copy': 'Копировать', 'settings.mcp.copied': 'Скопировано!', 'settings.mcp.apiTokens': 'API-токены', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 95c884a..35bdbd6 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -144,7 +144,7 @@ const zh: Record = { 'settings.mcp.title': 'MCP 配置', 'settings.mcp.endpoint': 'MCP 端点', 'settings.mcp.clientConfig': '客户端配置', - 'settings.mcp.clientConfigHint': '将 替换为下方列表中的 API 令牌。', + 'settings.mcp.clientConfigHint': '将 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。', 'settings.mcp.copy': '复制', 'settings.mcp.copied': '已复制!', 'settings.mcp.apiTokens': 'API 令牌', diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index b9ccf13..e049945 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -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 ' } - } + const mcpJsonConfig = `{ + "mcpServers": { + "trek": { + "command": "npx", + "args": [ + "mcp-remote", + "${mcpEndpoint}", + "--header", + "Authorization: Bearer " + ] } - }, null, 2) + } +}` // Map settings const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 21e6972..69462e9 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/index.ts b/server/src/index.ts index 9cb17b7..58b649a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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') { diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index dd21c81..97b3d5d 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -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(); 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(); + +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 { 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 { 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 { 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 { 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(); } diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 74d8813..a077419 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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 | 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 diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index d73701c..8ea84e7 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -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 & { 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 | 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 | 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 | 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 = {}; + 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 & { 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( diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index ca4f30c..c0a70ca 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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 }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 7b956f0..a0994fb 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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 }); }); diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index a48c2d4..36a3e35 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -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 = {}; + let linksMap: Record = {}; 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 }[]; diff --git a/server/tsconfig.json b/server/tsconfig.json index 25e99aa..b443a5b 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -9,7 +9,6 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "moduleResolution": "bundler", "resolveJsonModule": true, "declaration": false, "sourceMap": true,