diff --git a/client/package-lock.json b/client/package-lock.json index 9a80671..5dc9bc0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-client", - "version": "2.6.2", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.6.2", + "version": "2.7.0", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 7e8f28b..be8db87 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -61,6 +61,11 @@ export const authApi = { changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data), deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data), demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data), + mcpTokens: { + list: () => apiClient.get('/auth/mcp-tokens').then(r => r.data), + create: (name: string) => apiClient.post('/auth/mcp-tokens', { name }).then(r => r.data), + delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), + }, } export const tripsApi = { @@ -163,6 +168,8 @@ export const adminApi = { listInvites: () => apiClient.get('/admin/invites').then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), + mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data), + deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), } export const addonsApi = { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index ad9e539..3050258 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' +import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, } interface Addon { @@ -32,6 +33,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const dm = useSettingsStore(s => s.settings.dark_mode) const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const toast = useToast() + const refreshGlobalAddons = useAddonStore(s => s.loadAddons) const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) @@ -57,7 +59,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) try { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) - window.dispatchEvent(new Event('addons-changed')) + refreshGlobalAddons() toast.success(t('admin.addons.toast.updated')) } catch (err: unknown) { // Rollback @@ -68,6 +70,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } const tripAddons = addons.filter(a => a.type === 'trip') const globalAddons = addons.filter(a => a.type === 'global') + const integrationAddons = addons.filter(a => a.type === 'integration') if (loading) { return ( @@ -144,6 +147,21 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } ))} )} + + {/* Integration Addons */} + {integrationAddons.length > 0 && ( +
+
+ + + {t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')} + +
+ {integrationAddons.map(addon => ( + + ))} +
+ )} )} @@ -188,11 +206,8 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) { Coming Soon )} - - {addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')} + + {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}

{label.description}

diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx new file mode 100644 index 0000000..8a89f92 --- /dev/null +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect } from 'react' +import { adminApi } from '../../api/client' +import { useToast } from '../shared/Toast' +import { Key, Trash2, User, Loader2 } from 'lucide-react' +import { useTranslation } from '../../i18n' + +interface AdminMcpToken { + id: number + name: string + token_prefix: string + created_at: string + last_used_at: string | null + user_id: number + username: string +} + +export default function AdminMcpTokensPanel() { + const [tokens, setTokens] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const toast = useToast() + const { t, locale } = useTranslation() + + useEffect(() => { + setIsLoading(true) + adminApi.mcpTokens() + .then(d => setTokens(d.tokens || [])) + .catch(() => toast.error(t('admin.mcpTokens.loadError'))) + .finally(() => setIsLoading(false)) + }, []) + + const handleDelete = async (id: number) => { + try { + await adminApi.deleteMcpToken(id) + setTokens(prev => prev.filter(tk => tk.id !== id)) + setDeleteConfirmId(null) + toast.success(t('admin.mcpTokens.deleteSuccess')) + } catch { + toast.error(t('admin.mcpTokens.deleteError')) + } + } + + return ( +
+
+

{t('admin.mcpTokens.title')}

+

{t('admin.mcpTokens.subtitle')}

+
+ +
+ {isLoading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +
+ +

{t('admin.mcpTokens.empty')}

+
+ ) : ( + <> +
+ {t('admin.mcpTokens.tokenName')} + {t('admin.mcpTokens.owner')} + {t('admin.mcpTokens.created')} + {t('admin.mcpTokens.lastUsed')} + +
+ {tokens.map((token, i) => ( +
+
+

{token.name}

+

{token.token_prefix}...

+
+
+ + {token.username} +
+ + {new Date(token.created_at).toLocaleDateString(locale)} + + + {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} + + +
+ ))} + + )} +
+ + {deleteConfirmId !== null && ( +
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> +
+

{t('admin.mcpTokens.deleteTitle')}

+

{t('admin.mcpTokens.deleteMessage')}

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 0e85d14..ea19596 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -3,8 +3,8 @@ import ReactDOM from 'react-dom' import { Link, useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' +import { useAddonStore } from '../../store/addonStore' import { useTranslation } from '../../i18n' -import { addonsApi } from '../../api/client' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react' import type { LucideIcon } from 'lucide-react' @@ -28,29 +28,21 @@ interface Addon { export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { const { user, logout } = useAuthStore() const { settings, updateSetting } = useSettingsStore() + const { addons: allAddons, loadAddons } = useAddonStore() const { t, locale } = useTranslation() const navigate = useNavigate() const location = useLocation() const [userMenuOpen, setUserMenuOpen] = useState(false) const [appVersion, setAppVersion] = useState(null) - const [globalAddons, setGlobalAddons] = useState([]) const darkMode = settings.dark_mode const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const loadAddons = () => { - if (user) { - addonsApi.enabled().then(data => { - setGlobalAddons(data.addons.filter(a => a.type === 'global')) - }).catch(() => {}) - } - } - useEffect(loadAddons, [user, location.pathname]) - // Listen for addon changes from AddonManager + // Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page + const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled) + useEffect(() => { - const handler = () => loadAddons() - window.addEventListener('addons-changed', handler) - return () => window.removeEventListener('addons-changed', handler) - }, [user]) + if (user) loadAddons() + }, [user, location.pathname]) useEffect(() => { import('../../api/client').then(({ authApi }) => { diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index c216f35..4f1193c 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -146,6 +146,31 @@ const ar: Record = { 'settings.routeCalculation': 'حساب المسار', 'settings.on': 'تشغيل', 'settings.off': 'إيقاف', + 'settings.mcp.title': 'إعداد MCP', + 'settings.mcp.endpoint': 'نقطة نهاية MCP', + 'settings.mcp.clientConfig': 'إعداد العميل', + 'settings.mcp.clientConfigHint': 'استبدل برمز API من القائمة أدناه.', + 'settings.mcp.copy': 'نسخ', + 'settings.mcp.copied': 'تم النسخ!', + 'settings.mcp.apiTokens': 'رموز API', + 'settings.mcp.createToken': 'إنشاء رمز جديد', + 'settings.mcp.noTokens': 'لا توجد رموز بعد. أنشئ رمزاً للاتصال بعملاء MCP.', + 'settings.mcp.tokenCreatedAt': 'أُنشئ', + 'settings.mcp.tokenUsedAt': 'استُخدم', + 'settings.mcp.deleteTokenTitle': 'حذف الرمز', + 'settings.mcp.deleteTokenMessage': 'سيتوقف هذا الرمز عن العمل فوراً. أي عميل MCP يستخدمه سيفقد الوصول.', + 'settings.mcp.modal.createTitle': 'إنشاء رمز API', + 'settings.mcp.modal.tokenName': 'اسم الرمز', + 'settings.mcp.modal.tokenNamePlaceholder': 'مثال: Claude Desktop، حاسوب العمل', + 'settings.mcp.modal.creating': 'جارٍ الإنشاء…', + 'settings.mcp.modal.create': 'إنشاء الرمز', + 'settings.mcp.modal.createdTitle': 'تم إنشاء الرمز', + 'settings.mcp.modal.createdWarning': 'سيُعرض هذا الرمز مرة واحدة فقط. انسخه واحفظه الآن — لا يمكن استرداده.', + 'settings.mcp.modal.done': 'تم', + 'settings.mcp.toast.created': 'تم إنشاء الرمز', + 'settings.mcp.toast.createError': 'فشل إنشاء الرمز', + 'settings.mcp.toast.deleted': 'تم حذف الرمز', + 'settings.mcp.toast.deleteError': 'فشل حذف الرمز', 'settings.account': 'الحساب', 'settings.username': 'اسم المستخدم', 'settings.email': 'البريد الإلكتروني', @@ -280,6 +305,20 @@ const ar: Record = { 'admin.tabs.config': 'الإعدادات', 'admin.tabs.templates': 'قوالب التعبئة', 'admin.tabs.addons': 'الإضافات', + 'admin.tabs.mcpTokens': 'رموز MCP', + 'admin.mcpTokens.title': 'رموز MCP', + 'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين', + 'admin.mcpTokens.owner': 'المالك', + 'admin.mcpTokens.tokenName': 'اسم الرمز', + 'admin.mcpTokens.created': 'تاريخ الإنشاء', + 'admin.mcpTokens.lastUsed': 'آخر استخدام', + 'admin.mcpTokens.never': 'أبداً', + 'admin.mcpTokens.empty': 'لم يتم إنشاء أي رموز MCP بعد', + 'admin.mcpTokens.deleteTitle': 'حذف الرمز', + 'admin.mcpTokens.deleteMessage': 'سيتم إلغاء هذا الرمز فوراً. سيفقد المستخدم وصوله إلى MCP عبر هذا الرمز.', + 'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز', + 'admin.mcpTokens.deleteError': 'فشل حذف الرمز', + 'admin.mcpTokens.loadError': 'فشل تحميل الرموز', 'admin.tabs.github': 'GitHub', 'admin.stats.users': 'المستخدمون', 'admin.stats.trips': 'الرحلات', @@ -382,6 +421,8 @@ const ar: Record = { 'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.', 'admin.addons.catalog.memories.name': 'صور (Immich)', 'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي', 'admin.addons.catalog.packing.name': 'التعبئة', 'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة', 'admin.addons.catalog.budget.name': 'الميزانية', @@ -400,8 +441,10 @@ const ar: Record = { 'admin.addons.disabled': 'معطّل', 'admin.addons.type.trip': 'رحلة', 'admin.addons.type.global': 'عام', + 'admin.addons.type.integration': 'تكامل', 'admin.addons.tripHint': 'متاح كعلامة تبويب داخل كل رحلة', 'admin.addons.globalHint': 'متاح كقسم مستقل في التنقل الرئيسي', + 'admin.addons.integrationHint': 'خدمات الواجهة الخلفية وتكاملات API بدون صفحة مخصصة', 'admin.addons.toast.updated': 'تم تحديث الإضافة', 'admin.addons.toast.error': 'فشل تحديث الإضافة', 'admin.addons.noAddons': 'لا توجد إضافات متاحة', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index fdc437d..393d856 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -141,6 +141,31 @@ const de: Record = { 'settings.routeCalculation': 'Routenberechnung', 'settings.on': 'An', 'settings.off': 'Aus', + '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.copy': 'Kopieren', + 'settings.mcp.copied': 'Kopiert!', + 'settings.mcp.apiTokens': 'API-Tokens', + 'settings.mcp.createToken': 'Neuen Token erstellen', + 'settings.mcp.noTokens': 'Noch keine Tokens. Erstelle einen, um MCP-Clients zu verbinden.', + 'settings.mcp.tokenCreatedAt': 'Erstellt', + 'settings.mcp.tokenUsedAt': 'Verwendet', + 'settings.mcp.deleteTokenTitle': 'Token löschen', + 'settings.mcp.deleteTokenMessage': 'Dieser Token wird sofort ungültig. Jeder MCP-Client, der ihn verwendet, verliert den Zugang.', + 'settings.mcp.modal.createTitle': 'API-Token erstellen', + 'settings.mcp.modal.tokenName': 'Token-Name', + 'settings.mcp.modal.tokenNamePlaceholder': 'z. B. Claude Desktop, Arbeits-Laptop', + 'settings.mcp.modal.creating': 'Wird erstellt…', + 'settings.mcp.modal.create': 'Token erstellen', + 'settings.mcp.modal.createdTitle': 'Token erstellt', + 'settings.mcp.modal.createdWarning': 'Dieser Token wird nur einmal angezeigt. Kopiere und speichere ihn jetzt — er kann nicht wiederhergestellt werden.', + 'settings.mcp.modal.done': 'Fertig', + 'settings.mcp.toast.created': 'Token erstellt', + 'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden', + 'settings.mcp.toast.deleted': 'Token gelöscht', + 'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden', 'settings.account': 'Konto', 'settings.username': 'Benutzername', 'settings.email': 'E-Mail', @@ -390,14 +415,18 @@ const de: Record = { 'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung', 'admin.addons.catalog.memories.name': 'Fotos (Immich)', 'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'Model Context Protocol für die KI-Assistenten-Integration', 'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ', 'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.', 'admin.addons.enabled': 'Aktiviert', 'admin.addons.disabled': 'Deaktiviert', 'admin.addons.type.trip': 'Trip', 'admin.addons.type.global': 'Global', + 'admin.addons.type.integration': 'Integration', 'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips', 'admin.addons.globalHint': 'Verfügbar als eigenständiger Bereich in der Navigation', + 'admin.addons.integrationHint': 'Backend-Dienste und API-Integrationen ohne eigene Seite', 'admin.addons.toast.updated': 'Addon aktualisiert', 'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden', 'admin.addons.noAddons': 'Keine Addons verfügbar', @@ -413,6 +442,22 @@ const de: Record = { 'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich', 'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.', + // MCP Tokens + 'admin.tabs.mcpTokens': 'MCP-Tokens', + 'admin.mcpTokens.title': 'MCP-Tokens', + 'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten', + 'admin.mcpTokens.owner': 'Besitzer', + 'admin.mcpTokens.tokenName': 'Token-Name', + 'admin.mcpTokens.created': 'Erstellt', + 'admin.mcpTokens.lastUsed': 'Zuletzt verwendet', + 'admin.mcpTokens.never': 'Nie', + 'admin.mcpTokens.empty': 'Es wurden noch keine MCP-Tokens erstellt', + 'admin.mcpTokens.deleteTitle': 'Token löschen', + 'admin.mcpTokens.deleteMessage': 'Dieser Token wird sofort widerrufen. Der Benutzer verliert den MCP-Zugang über diesen Token.', + 'admin.mcpTokens.deleteSuccess': 'Token gelöscht', + 'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden', + 'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden', + // GitHub 'admin.tabs.github': 'GitHub', 'admin.github.title': 'Update-Verlauf', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 4a89978..9acbb08 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -141,6 +141,31 @@ const en: Record = { 'settings.routeCalculation': 'Route Calculation', 'settings.on': 'On', 'settings.off': 'Off', + '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.copy': 'Copy', + 'settings.mcp.copied': 'Copied!', + 'settings.mcp.apiTokens': 'API Tokens', + 'settings.mcp.createToken': 'Create New Token', + 'settings.mcp.noTokens': 'No tokens yet. Create one to connect MCP clients.', + 'settings.mcp.tokenCreatedAt': 'Created', + 'settings.mcp.tokenUsedAt': 'Used', + 'settings.mcp.deleteTokenTitle': 'Delete Token', + 'settings.mcp.deleteTokenMessage': 'This token will stop working immediately. Any MCP client using it will lose access.', + 'settings.mcp.modal.createTitle': 'Create API Token', + 'settings.mcp.modal.tokenName': 'Token Name', + 'settings.mcp.modal.tokenNamePlaceholder': 'e.g. Claude Desktop, Work laptop', + 'settings.mcp.modal.creating': 'Creating…', + 'settings.mcp.modal.create': 'Create Token', + 'settings.mcp.modal.createdTitle': 'Token Created', + 'settings.mcp.modal.createdWarning': 'This token will only be shown once. Copy and store it now — it cannot be recovered.', + 'settings.mcp.modal.done': 'Done', + 'settings.mcp.toast.created': 'Token created', + 'settings.mcp.toast.createError': 'Failed to create token', + 'settings.mcp.toast.deleted': 'Token deleted', + 'settings.mcp.toast.deleteError': 'Failed to delete token', 'settings.account': 'Account', 'settings.username': 'Username', 'settings.email': 'Email', @@ -390,14 +415,18 @@ const en: Record = { 'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning', 'admin.addons.catalog.memories.name': 'Photos (Immich)', 'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'Model Context Protocol for AI assistant integration', 'admin.addons.subtitleBefore': 'Enable or disable features to customize your ', 'admin.addons.subtitleAfter': ' experience.', 'admin.addons.enabled': 'Enabled', 'admin.addons.disabled': 'Disabled', 'admin.addons.type.trip': 'Trip', 'admin.addons.type.global': 'Global', + 'admin.addons.type.integration': 'Integration', 'admin.addons.tripHint': 'Available as a tab within each trip', 'admin.addons.globalHint': 'Available as a standalone section in the main navigation', + 'admin.addons.integrationHint': 'Backend services and API integrations with no dedicated page', 'admin.addons.toast.updated': 'Addon updated', 'admin.addons.toast.error': 'Failed to update addon', 'admin.addons.noAddons': 'No addons available', @@ -414,6 +443,20 @@ const en: Record = { 'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.', // GitHub + 'admin.tabs.mcpTokens': 'MCP Tokens', + 'admin.mcpTokens.title': 'MCP Tokens', + 'admin.mcpTokens.subtitle': 'Manage API tokens across all users', + 'admin.mcpTokens.owner': 'Owner', + 'admin.mcpTokens.tokenName': 'Token Name', + 'admin.mcpTokens.created': 'Created', + 'admin.mcpTokens.lastUsed': 'Last Used', + 'admin.mcpTokens.never': 'Never', + 'admin.mcpTokens.empty': 'No MCP tokens have been created yet', + 'admin.mcpTokens.deleteTitle': 'Delete Token', + 'admin.mcpTokens.deleteMessage': 'This will revoke the token immediately. The user will lose MCP access through this token.', + 'admin.mcpTokens.deleteSuccess': 'Token deleted', + 'admin.mcpTokens.deleteError': 'Failed to delete token', + 'admin.mcpTokens.loadError': 'Failed to load tokens', 'admin.tabs.github': 'GitHub', 'admin.github.title': 'Release History', 'admin.github.subtitle': 'Latest updates from {repo}', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index e8448fc..938c5a0 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -142,6 +142,31 @@ const es: Record = { 'settings.routeCalculation': 'Cálculo de ruta', 'settings.on': 'Activado', 'settings.off': 'Desactivado', + '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.copy': 'Copiar', + 'settings.mcp.copied': '¡Copiado!', + 'settings.mcp.apiTokens': 'Tokens de API', + 'settings.mcp.createToken': 'Crear nuevo token', + 'settings.mcp.noTokens': 'Sin tokens aún. Crea uno para conectar clientes MCP.', + 'settings.mcp.tokenCreatedAt': 'Creado', + 'settings.mcp.tokenUsedAt': 'Usado', + 'settings.mcp.deleteTokenTitle': 'Eliminar token', + 'settings.mcp.deleteTokenMessage': 'Este token dejará de funcionar de inmediato. Cualquier cliente MCP que lo use perderá el acceso.', + 'settings.mcp.modal.createTitle': 'Crear token de API', + 'settings.mcp.modal.tokenName': 'Nombre del token', + 'settings.mcp.modal.tokenNamePlaceholder': 'p. ej. Claude Desktop, Portátil de trabajo', + 'settings.mcp.modal.creating': 'Creando…', + 'settings.mcp.modal.create': 'Crear token', + 'settings.mcp.modal.createdTitle': 'Token creado', + 'settings.mcp.modal.createdWarning': 'Este token solo se mostrará una vez. Cópialo y guárdalo ahora — no se podrá recuperar.', + 'settings.mcp.modal.done': 'Listo', + 'settings.mcp.toast.created': 'Token creado', + 'settings.mcp.toast.createError': 'Error al crear el token', + 'settings.mcp.toast.deleted': 'Token eliminado', + 'settings.mcp.toast.deleteError': 'Error al eliminar el token', 'settings.account': 'Cuenta', 'settings.username': 'Usuario', 'settings.email': 'Correo', @@ -375,8 +400,10 @@ const es: Record = { 'admin.addons.disabled': 'Desactivado', 'admin.addons.type.trip': 'Viaje', 'admin.addons.type.global': 'Global', + 'admin.addons.type.integration': 'Integración', 'admin.addons.tripHint': 'Disponible como pestaña dentro de cada viaje', 'admin.addons.globalHint': 'Disponible como sección independiente en la navegación principal', + 'admin.addons.integrationHint': 'Servicios backend e integraciones de API sin página dedicada', 'admin.addons.toast.updated': 'Complemento actualizado', 'admin.addons.toast.error': 'No se pudo actualizar el complemento', 'admin.addons.noAddons': 'No hay complementos disponibles', @@ -391,6 +418,22 @@ const es: Record = { 'admin.weather.requestsDesc': 'Gratis, sin necesidad de clave API', 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.', + // MCP Tokens + 'admin.tabs.mcpTokens': 'Tokens MCP', + 'admin.mcpTokens.title': 'Tokens MCP', + 'admin.mcpTokens.subtitle': 'Gestionar tokens de API de todos los usuarios', + 'admin.mcpTokens.owner': 'Propietario', + 'admin.mcpTokens.tokenName': 'Nombre del token', + 'admin.mcpTokens.created': 'Creado', + 'admin.mcpTokens.lastUsed': 'Último uso', + 'admin.mcpTokens.never': 'Nunca', + 'admin.mcpTokens.empty': 'Aún no se han creado tokens MCP', + 'admin.mcpTokens.deleteTitle': 'Eliminar token', + 'admin.mcpTokens.deleteMessage': 'Este token se revocará inmediatamente. El usuario perderá el acceso MCP a través de este token.', + 'admin.mcpTokens.deleteSuccess': 'Token eliminado', + 'admin.mcpTokens.deleteError': 'No se pudo eliminar el token', + 'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens', + // GitHub 'admin.tabs.github': 'GitHub', 'admin.github.title': 'Historial de versiones', @@ -947,6 +990,8 @@ const es: Record = { 'photos.uploadN': 'Subida de {n} foto(s)', 'admin.addons.catalog.memories.name': 'Fotos (Immich)', 'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA', 'admin.addons.catalog.packing.name': 'Equipaje', 'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje', 'admin.addons.catalog.budget.name': 'Presupuesto', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 51e95cd..158a1d3 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -141,6 +141,31 @@ const fr: Record = { 'settings.routeCalculation': 'Calcul d\'itinéraire', 'settings.on': 'Activé', 'settings.off': 'Désactivé', + '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.copy': 'Copier', + 'settings.mcp.copied': 'Copié !', + 'settings.mcp.apiTokens': 'Tokens API', + 'settings.mcp.createToken': 'Créer un token', + 'settings.mcp.noTokens': 'Aucun token pour l\'instant. Créez-en un pour connecter des clients MCP.', + 'settings.mcp.tokenCreatedAt': 'Créé', + 'settings.mcp.tokenUsedAt': 'Utilisé', + 'settings.mcp.deleteTokenTitle': 'Supprimer le token', + 'settings.mcp.deleteTokenMessage': 'Ce token cessera de fonctionner immédiatement. Tout client MCP l\'utilisant perdra l\'accès.', + 'settings.mcp.modal.createTitle': 'Créer un token API', + 'settings.mcp.modal.tokenName': 'Nom du token', + 'settings.mcp.modal.tokenNamePlaceholder': 'ex. Claude Desktop, Ordinateur pro', + 'settings.mcp.modal.creating': 'Création…', + 'settings.mcp.modal.create': 'Créer le token', + 'settings.mcp.modal.createdTitle': 'Token créé', + 'settings.mcp.modal.createdWarning': 'Ce token ne sera affiché qu\'une seule fois. Copiez-le et conservez-le maintenant — il ne pourra pas être récupéré.', + 'settings.mcp.modal.done': 'Terminé', + 'settings.mcp.toast.created': 'Token créé', + 'settings.mcp.toast.createError': 'Impossible de créer le token', + 'settings.mcp.toast.deleted': 'Token supprimé', + 'settings.mcp.toast.deleteError': 'Impossible de supprimer le token', 'settings.account': 'Compte', 'settings.username': 'Nom d\'utilisateur', 'settings.email': 'E-mail', @@ -375,6 +400,8 @@ const fr: Record = { 'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.', 'admin.addons.catalog.memories.name': 'Photos (Immich)', 'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA', 'admin.addons.catalog.packing.name': 'Bagages', 'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage', 'admin.addons.catalog.budget.name': 'Budget', @@ -393,8 +420,10 @@ const fr: Record = { 'admin.addons.disabled': 'Désactivé', 'admin.addons.type.trip': 'Voyage', 'admin.addons.type.global': 'Global', + 'admin.addons.type.integration': 'Intégration', 'admin.addons.tripHint': 'Disponible comme onglet dans chaque voyage', 'admin.addons.globalHint': 'Disponible comme section autonome dans la navigation principale', + 'admin.addons.integrationHint': 'Services backend et intégrations API sans page dédiée', 'admin.addons.toast.updated': 'Extension mise à jour', 'admin.addons.toast.error': 'Échec de la mise à jour de l\'extension', 'admin.addons.noAddons': 'Aucune extension disponible', @@ -410,6 +439,22 @@ const fr: Record = { 'admin.weather.requestsDesc': 'Gratuit, aucune clé API requise', 'admin.weather.locationHint': 'La météo est basée sur le premier lieu avec des coordonnées de chaque jour. Si aucun lieu n\'est assigné à un jour, un lieu de la liste est utilisé comme référence.', + // MCP Tokens + 'admin.tabs.mcpTokens': 'Tokens MCP', + 'admin.mcpTokens.title': 'Tokens MCP', + 'admin.mcpTokens.subtitle': 'Gérer les tokens API de tous les utilisateurs', + 'admin.mcpTokens.owner': 'Propriétaire', + 'admin.mcpTokens.tokenName': 'Nom du token', + 'admin.mcpTokens.created': 'Créé', + 'admin.mcpTokens.lastUsed': 'Dernière utilisation', + 'admin.mcpTokens.never': 'Jamais', + 'admin.mcpTokens.empty': 'Aucun token MCP n\'a encore été créé', + 'admin.mcpTokens.deleteTitle': 'Supprimer le token', + 'admin.mcpTokens.deleteMessage': 'Ce token sera révoqué immédiatement. L\'utilisateur perdra l\'accès MCP via ce token.', + 'admin.mcpTokens.deleteSuccess': 'Token supprimé', + 'admin.mcpTokens.deleteError': 'Impossible de supprimer le token', + 'admin.mcpTokens.loadError': 'Impossible de charger les tokens', + // GitHub 'admin.tabs.github': 'GitHub', 'admin.github.title': 'Historique des versions', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 89407f7..8d24061 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -141,6 +141,31 @@ const nl: Record = { 'settings.routeCalculation': 'Routeberekening', 'settings.on': 'Aan', 'settings.off': 'Uit', + '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.copy': 'Kopiëren', + 'settings.mcp.copied': 'Gekopieerd!', + 'settings.mcp.apiTokens': 'API-tokens', + 'settings.mcp.createToken': 'Nieuw token aanmaken', + 'settings.mcp.noTokens': 'Nog geen tokens. Maak er een aan om MCP-clients te verbinden.', + 'settings.mcp.tokenCreatedAt': 'Aangemaakt', + 'settings.mcp.tokenUsedAt': 'Gebruikt', + 'settings.mcp.deleteTokenTitle': 'Token verwijderen', + 'settings.mcp.deleteTokenMessage': 'Dit token werkt onmiddellijk niet meer. Elke MCP-client die het gebruikt verliest de toegang.', + 'settings.mcp.modal.createTitle': 'API-token aanmaken', + 'settings.mcp.modal.tokenName': 'Tokennaam', + 'settings.mcp.modal.tokenNamePlaceholder': 'bijv. Claude Desktop, Werklaptop', + 'settings.mcp.modal.creating': 'Aanmaken…', + 'settings.mcp.modal.create': 'Token aanmaken', + 'settings.mcp.modal.createdTitle': 'Token aangemaakt', + 'settings.mcp.modal.createdWarning': 'Dit token wordt slechts één keer getoond. Kopieer en bewaar het nu — het kan niet worden hersteld.', + 'settings.mcp.modal.done': 'Klaar', + 'settings.mcp.toast.created': 'Token aangemaakt', + 'settings.mcp.toast.createError': 'Token aanmaken mislukt', + 'settings.mcp.toast.deleted': 'Token verwijderd', + 'settings.mcp.toast.deleteError': 'Token verwijderen mislukt', 'settings.account': 'Account', 'settings.username': 'Gebruikersnaam', 'settings.email': 'E-mail', @@ -375,6 +400,8 @@ const nl: Record = { 'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.', 'admin.addons.catalog.memories.name': 'Foto\'s (Immich)', 'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie', 'admin.addons.catalog.packing.name': 'Inpakken', 'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden', 'admin.addons.catalog.budget.name': 'Budget', @@ -393,8 +420,10 @@ const nl: Record = { 'admin.addons.disabled': 'Uitgeschakeld', 'admin.addons.type.trip': 'Reis', 'admin.addons.type.global': 'Globaal', + 'admin.addons.type.integration': 'Integratie', 'admin.addons.tripHint': 'Beschikbaar als tabblad binnen elke reis', 'admin.addons.globalHint': 'Beschikbaar als zelfstandig onderdeel in de hoofdnavigatie', + 'admin.addons.integrationHint': 'Backenddiensten en API-integraties zonder eigen pagina', 'admin.addons.toast.updated': 'Add-on bijgewerkt', 'admin.addons.toast.error': 'Add-on bijwerken mislukt', 'admin.addons.noAddons': 'Geen add-ons beschikbaar', @@ -410,6 +439,22 @@ const nl: Record = { 'admin.weather.requestsDesc': 'Gratis, geen API-sleutel vereist', 'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.', + // MCP Tokens + 'admin.tabs.mcpTokens': 'MCP-tokens', + 'admin.mcpTokens.title': 'MCP-tokens', + 'admin.mcpTokens.subtitle': 'API-tokens van alle gebruikers beheren', + 'admin.mcpTokens.owner': 'Eigenaar', + 'admin.mcpTokens.tokenName': 'Tokennaam', + 'admin.mcpTokens.created': 'Aangemaakt', + 'admin.mcpTokens.lastUsed': 'Laatst gebruikt', + 'admin.mcpTokens.never': 'Nooit', + 'admin.mcpTokens.empty': 'Er zijn nog geen MCP-tokens aangemaakt', + 'admin.mcpTokens.deleteTitle': 'Token verwijderen', + 'admin.mcpTokens.deleteMessage': 'Dit token wordt onmiddellijk ingetrokken. De gebruiker verliest MCP-toegang via dit token.', + 'admin.mcpTokens.deleteSuccess': 'Token verwijderd', + 'admin.mcpTokens.deleteError': 'Token kon niet worden verwijderd', + 'admin.mcpTokens.loadError': 'Tokens konden niet worden geladen', + // GitHub 'admin.tabs.github': 'GitHub', 'admin.github.title': 'Release-geschiedenis', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 75d29ba..d6cdb82 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -141,6 +141,31 @@ const ru: Record = { 'settings.routeCalculation': 'Расчёт маршрута', 'settings.on': 'Вкл.', 'settings.off': 'Выкл.', + 'settings.mcp.title': 'Настройка MCP', + 'settings.mcp.endpoint': 'MCP-эндпоинт', + 'settings.mcp.clientConfig': 'Конфигурация клиента', + 'settings.mcp.clientConfigHint': 'Замените на API-токен из списка ниже.', + 'settings.mcp.copy': 'Копировать', + 'settings.mcp.copied': 'Скопировано!', + 'settings.mcp.apiTokens': 'API-токены', + 'settings.mcp.createToken': 'Создать токен', + 'settings.mcp.noTokens': 'Токенов пока нет. Создайте один для подключения MCP-клиентов.', + 'settings.mcp.tokenCreatedAt': 'Создан', + 'settings.mcp.tokenUsedAt': 'Использован', + 'settings.mcp.deleteTokenTitle': 'Удалить токен', + 'settings.mcp.deleteTokenMessage': 'Этот токен перестанет работать немедленно. Любой MCP-клиент, использующий его, потеряет доступ.', + 'settings.mcp.modal.createTitle': 'Создать API-токен', + 'settings.mcp.modal.tokenName': 'Название токена', + 'settings.mcp.modal.tokenNamePlaceholder': 'напр. Claude Desktop, Рабочий ноутбук', + 'settings.mcp.modal.creating': 'Создание…', + 'settings.mcp.modal.create': 'Создать токен', + 'settings.mcp.modal.createdTitle': 'Токен создан', + 'settings.mcp.modal.createdWarning': 'Этот токен будет показан только один раз. Скопируйте и сохраните его сейчас — восстановить его будет невозможно.', + 'settings.mcp.modal.done': 'Готово', + 'settings.mcp.toast.created': 'Токен создан', + 'settings.mcp.toast.createError': 'Не удалось создать токен', + 'settings.mcp.toast.deleted': 'Токен удалён', + 'settings.mcp.toast.deleteError': 'Не удалось удалить токен', 'settings.account': 'Аккаунт', 'settings.username': 'Имя пользователя', 'settings.email': 'Эл. почта', @@ -375,6 +400,8 @@ const ru: Record = { 'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.', 'admin.addons.catalog.memories.name': 'Фото (Immich)', 'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': 'Протокол контекста модели для интеграции с ИИ-ассистентами', 'admin.addons.catalog.packing.name': 'Сборы', 'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке', 'admin.addons.catalog.budget.name': 'Бюджет', @@ -393,8 +420,10 @@ const ru: Record = { 'admin.addons.disabled': 'Отключено', 'admin.addons.type.trip': 'Поездка', 'admin.addons.type.global': 'Глобально', + 'admin.addons.type.integration': 'Интеграция', 'admin.addons.tripHint': 'Доступно как вкладка внутри каждой поездки', 'admin.addons.globalHint': 'Доступно как отдельный раздел в основной навигации', + 'admin.addons.integrationHint': 'Фоновые сервисы и API-интеграции без отдельной страницы', 'admin.addons.toast.updated': 'Дополнение обновлено', 'admin.addons.toast.error': 'Не удалось обновить дополнение', 'admin.addons.noAddons': 'Нет доступных дополнений', @@ -410,6 +439,22 @@ const ru: Record = { 'admin.weather.requestsDesc': 'Бесплатно, API-ключ не требуется', 'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.', + // MCP Tokens + 'admin.tabs.mcpTokens': 'MCP-токены', + 'admin.mcpTokens.title': 'MCP-токены', + 'admin.mcpTokens.subtitle': 'Управление API-токенами всех пользователей', + 'admin.mcpTokens.owner': 'Владелец', + 'admin.mcpTokens.tokenName': 'Название токена', + 'admin.mcpTokens.created': 'Создан', + 'admin.mcpTokens.lastUsed': 'Последнее использование', + 'admin.mcpTokens.never': 'Никогда', + 'admin.mcpTokens.empty': 'MCP-токены ещё не созданы', + 'admin.mcpTokens.deleteTitle': 'Удалить токен', + 'admin.mcpTokens.deleteMessage': 'Токен будет немедленно отозван. Пользователь потеряет доступ к MCP через этот токен.', + 'admin.mcpTokens.deleteSuccess': 'Токен удалён', + 'admin.mcpTokens.deleteError': 'Не удалось удалить токен', + 'admin.mcpTokens.loadError': 'Не удалось загрузить токены', + // GitHub 'admin.tabs.github': 'GitHub', 'admin.github.title': 'История релизов', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index fbe1121..1069d46 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -141,6 +141,31 @@ const zh: Record = { 'settings.routeCalculation': '路线计算', 'settings.on': '开', 'settings.off': '关', + 'settings.mcp.title': 'MCP 配置', + 'settings.mcp.endpoint': 'MCP 端点', + 'settings.mcp.clientConfig': '客户端配置', + 'settings.mcp.clientConfigHint': '将 替换为下方列表中的 API 令牌。', + 'settings.mcp.copy': '复制', + 'settings.mcp.copied': '已复制!', + 'settings.mcp.apiTokens': 'API 令牌', + 'settings.mcp.createToken': '创建新令牌', + 'settings.mcp.noTokens': '暂无令牌,请创建一个以连接 MCP 客户端。', + 'settings.mcp.tokenCreatedAt': '创建于', + 'settings.mcp.tokenUsedAt': '使用于', + 'settings.mcp.deleteTokenTitle': '删除令牌', + 'settings.mcp.deleteTokenMessage': '此令牌将立即失效,使用它的所有 MCP 客户端将失去访问权限。', + 'settings.mcp.modal.createTitle': '创建 API 令牌', + 'settings.mcp.modal.tokenName': '令牌名称', + 'settings.mcp.modal.tokenNamePlaceholder': '例如:Claude Desktop、工作电脑', + 'settings.mcp.modal.creating': '创建中…', + 'settings.mcp.modal.create': '创建令牌', + 'settings.mcp.modal.createdTitle': '令牌已创建', + 'settings.mcp.modal.createdWarning': '此令牌只会显示一次,请立即复制并妥善保存——无法找回。', + 'settings.mcp.modal.done': '完成', + 'settings.mcp.toast.created': '令牌已创建', + 'settings.mcp.toast.createError': '创建令牌失败', + 'settings.mcp.toast.deleted': '令牌已删除', + 'settings.mcp.toast.deleteError': '删除令牌失败', 'settings.account': '账户', 'settings.username': '用户名', 'settings.email': '邮箱', @@ -375,6 +400,8 @@ const zh: Record = { 'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。', 'admin.addons.catalog.memories.name': '照片 (Immich)', 'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片', + 'admin.addons.catalog.mcp.name': 'MCP', + 'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议', 'admin.addons.catalog.packing.name': '行李', 'admin.addons.catalog.packing.description': '每次旅行的行李准备清单', 'admin.addons.catalog.budget.name': '预算', @@ -393,8 +420,10 @@ const zh: Record = { 'admin.addons.disabled': '已禁用', 'admin.addons.type.trip': '旅行', 'admin.addons.type.global': '全局', + 'admin.addons.type.integration': '集成', 'admin.addons.tripHint': '在每次旅行中作为标签页显示', 'admin.addons.globalHint': '在主导航中作为独立板块显示', + 'admin.addons.integrationHint': '后端服务和 API 集成,无专属页面', 'admin.addons.toast.updated': '扩展已更新', 'admin.addons.toast.error': '更新扩展失败', 'admin.addons.noAddons': '暂无可用扩展', @@ -410,6 +439,22 @@ const zh: Record = { 'admin.weather.requestsDesc': '免费,无需 API 密钥', 'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。', + // MCP Tokens + 'admin.tabs.mcpTokens': 'MCP 令牌', + 'admin.mcpTokens.title': 'MCP 令牌', + 'admin.mcpTokens.subtitle': '管理所有用户的 API 令牌', + 'admin.mcpTokens.owner': '所有者', + 'admin.mcpTokens.tokenName': '令牌名称', + 'admin.mcpTokens.created': '创建时间', + 'admin.mcpTokens.lastUsed': '最后使用', + 'admin.mcpTokens.never': '从未', + 'admin.mcpTokens.empty': '尚未创建任何 MCP 令牌', + 'admin.mcpTokens.deleteTitle': '删除令牌', + 'admin.mcpTokens.deleteMessage': '此令牌将立即被撤销。用户将失去通过此令牌的 MCP 访问权限。', + 'admin.mcpTokens.deleteSuccess': '令牌已删除', + 'admin.mcpTokens.deleteError': '删除令牌失败', + 'admin.mcpTokens.loadError': '加载令牌失败', + // GitHub 'admin.tabs.github': 'GitHub', 'admin.github.title': '版本历史', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b63306f..b307537 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -13,6 +13,7 @@ import BackupPanel from '../components/Admin/BackupPanel' import GitHubPanel from '../components/Admin/GitHubPanel' import AddonManager from '../components/Admin/AddonManager' import PackingTemplateManager from '../components/Admin/PackingTemplateManager' +import AdminMcpTokensPanel from '../components/Admin/AdminMcpTokensPanel' import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun, Link2, Copy, Plus } from 'lucide-react' import CustomSelect from '../components/shared/CustomSelect' @@ -61,6 +62,7 @@ export default function AdminPage(): React.ReactElement { { id: 'addons', label: t('admin.tabs.addons') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, + { id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }, { id: 'github', label: t('admin.tabs.github') }, ] @@ -923,6 +925,8 @@ export default function AdminPage(): React.ReactElement { {activeTab === 'backup' && } + {activeTab === 'mcp-tokens' && } + {activeTab === 'github' && } diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 7846f5e..b9ccf13 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -6,9 +6,10 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import CustomSelect from '../components/shared/CustomSelect' import { useToast } from '../components/shared/Toast' -import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react' +import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, Terminal, Copy, Plus, Check } from 'lucide-react' import { authApi, adminApi } from '../api/client' import apiClient from '../api/client' +import { useAddonStore } from '../store/addonStore' import type { LucideIcon } from 'lucide-react' import type { UserWithOidc } from '../types' import { getApiErrorMessage } from '../types' @@ -18,6 +19,14 @@ interface MapPreset { url: string } +interface McpToken { + id: number + name: string + token_prefix: string + created_at: string + last_used_at: string | null +} + const MAP_PRESETS: MapPreset[] = [ { name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }, { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, @@ -51,32 +60,34 @@ export default function SettingsPage(): React.ReactElement { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const avatarInputRef = React.useRef(null) const { settings, updateSetting, updateSettings } = useSettingsStore() + const { isEnabled: addonEnabled, loadAddons } = useAddonStore() const { t, locale } = useTranslation() const toast = useToast() const navigate = useNavigate() const [saving, setSaving] = useState>({}) - // Immich - const [memoriesEnabled, setMemoriesEnabled] = useState(false) + // Addon gating (derived from store) + const memoriesEnabled = addonEnabled('memories') + const mcpEnabled = addonEnabled('mcp') const [immichUrl, setImmichUrl] = useState('') const [immichApiKey, setImmichApiKey] = useState('') const [immichConnected, setImmichConnected] = useState(false) const [immichTesting, setImmichTesting] = useState(false) useEffect(() => { - apiClient.get('/addons').then(r => { - const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled) - setMemoriesEnabled(!!mem) - if (mem) { - apiClient.get('/integrations/immich/settings').then(r2 => { - setImmichUrl(r2.data.immich_url || '') - setImmichConnected(r2.data.connected) - }).catch(() => {}) - } - }).catch(() => {}) + loadAddons() }, []) + useEffect(() => { + if (memoriesEnabled) { + apiClient.get('/integrations/immich/settings').then(r2 => { + setImmichUrl(r2.data.immich_url || '') + setImmichConnected(r2.data.connected) + }).catch(() => {}) + } + }, [memoriesEnabled]) + const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { @@ -110,6 +121,62 @@ export default function SettingsPage(): React.ReactElement { } } + // MCP tokens + const [mcpTokens, setMcpTokens] = useState([]) + const [mcpModalOpen, setMcpModalOpen] = useState(false) + const [mcpNewName, setMcpNewName] = useState('') + const [mcpCreatedToken, setMcpCreatedToken] = useState(null) + const [mcpCreating, setMcpCreating] = useState(false) + const [mcpDeleteId, setMcpDeleteId] = useState(null) + const [copiedKey, setCopiedKey] = useState(null) + + useEffect(() => { + authApi.mcpTokens.list().then(d => setMcpTokens(d.tokens || [])).catch(() => {}) + }, []) + + const handleCreateMcpToken = async () => { + if (!mcpNewName.trim()) return + setMcpCreating(true) + try { + const d = await authApi.mcpTokens.create(mcpNewName.trim()) + setMcpCreatedToken(d.token.raw_token) + setMcpNewName('') + setMcpTokens(prev => [{ id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, ...prev]) + } catch { + toast.error(t('settings.mcp.toast.createError')) + } finally { + setMcpCreating(false) + } + } + + const handleDeleteMcpToken = async (id: number) => { + try { + await authApi.mcpTokens.delete(id) + setMcpTokens(prev => prev.filter(tk => tk.id !== id)) + setMcpDeleteId(null) + toast.success(t('settings.mcp.toast.deleted')) + } catch { + toast.error(t('settings.mcp.toast.deleteError')) + } + } + + const handleCopy = (text: string, key: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopiedKey(key) + setTimeout(() => setCopiedKey(null), 2000) + }) + } + + const mcpEndpoint = `${window.location.origin}/mcp` + const mcpJsonConfig = JSON.stringify({ + mcpServers: { + trek: { + url: mcpEndpoint, + headers: { Authorization: 'Bearer ' } + } + } + }, null, 2) + // Map settings const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) @@ -480,6 +547,162 @@ export default function SettingsPage(): React.ReactElement { )} + {/* MCP Configuration — only when MCP addon is enabled */} + {mcpEnabled &&
+ {/* Endpoint URL */} +
+ +
+ + {mcpEndpoint} + + +
+
+ + {/* JSON config box */} +
+
+ + +
+
+                {mcpJsonConfig}
+              
+

{t('settings.mcp.clientConfigHint')}

+
+ + {/* Token list */} +
+
+ + +
+ + {mcpTokens.length === 0 ? ( +

+ {t('settings.mcp.noTokens')} +

+ ) : ( +
+ {mcpTokens.map((token, i) => ( +
+
+

{token.name}

+

+ {token.token_prefix}... + {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)} + {token.last_used_at && ( + · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)} + )} +

+
+ +
+ ))} +
+ )} +
+
} + + {/* Create MCP Token modal */} + {mcpModalOpen && ( +
{ if (e.target === e.currentTarget && !mcpCreatedToken) { setMcpModalOpen(false) } }}> +
+ {!mcpCreatedToken ? ( + <> +

{t('settings.mcp.modal.createTitle')}

+
+ + setMcpNewName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} + placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} + className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300" + style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} + autoFocus /> +
+
+ + +
+ + ) : ( + <> +

{t('settings.mcp.modal.createdTitle')}

+
+ +

{t('settings.mcp.modal.createdWarning')}

+
+
+
+                        {mcpCreatedToken}
+                      
+ +
+
+ +
+ + )} +
+
+ )} + + {/* Delete MCP Token confirm */} + {mcpDeleteId !== null && ( +
{ if (e.target === e.currentTarget) setMcpDeleteId(null) }}> +
+

{t('settings.mcp.deleteTokenTitle')}

+

{t('settings.mcp.deleteTokenMessage')}

+
+ + +
+
+
+ )} + {/* Account */}
diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts new file mode 100644 index 0000000..d0fce97 --- /dev/null +++ b/client/src/store/addonStore.ts @@ -0,0 +1,35 @@ +import { create } from 'zustand' +import { addonsApi } from '../api/client' + +interface Addon { + id: string + name: string + type: string + icon: string + enabled: boolean +} + +interface AddonState { + addons: Addon[] + loaded: boolean + loadAddons: () => Promise + isEnabled: (id: string) => boolean +} + +export const useAddonStore = create((set, get) => ({ + addons: [], + loaded: false, + + loadAddons: async () => { + try { + const data = await addonsApi.enabled() + set({ addons: data.addons || [], loaded: true }) + } catch { + set({ loaded: true }) + } + }, + + isEnabled: (id: string) => { + return get().addons.some(a => a.id === id && a.enabled) + }, +})) diff --git a/client/vite.config.js b/client/vite.config.js index a2d3632..31edd4c 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -10,7 +10,7 @@ export default defineConfig({ workbox: { globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'], navigateFallback: 'index.html', - navigateFallbackDenylist: [/^\/api/, /^\/uploads/], + navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/], runtimeCaching: [ { // Carto map tiles (default provider) @@ -100,6 +100,10 @@ export default defineConfig({ '/ws': { target: 'http://localhost:3001', ws: true, + }, + '/mcp': { + target: 'http://localhost:3001', + changeOrigin: true, } } } diff --git a/server/package-lock.json b/server/package-lock.json index dd28711..71235bf 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,13 +1,14 @@ { "name": "trek-server", - "version": "2.6.2", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-server", - "version": "2.6.2", + "version": "2.7.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.28.0", "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", @@ -25,7 +26,8 @@ "typescript": "^6.0.2", "unzipper": "^0.12.3", "uuid": "^9.0.0", - "ws": "^8.19.0" + "ws": "^8.19.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/archiver": "^7.0.0", @@ -460,6 +462,358 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", + "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@otplib/core": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", @@ -760,6 +1114,39 @@ "node": ">= 0.6" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1368,6 +1755,20 @@ "node": ">= 12.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1643,6 +2044,27 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1698,12 +2120,52 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2006,6 +2468,15 @@ "node": ">=18.0.0" } }, + "node_modules/hono": { + "version": "4.12.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", + "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2088,6 +2559,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2152,12 +2632,45 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -2690,6 +3203,15 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -2709,6 +3231,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -2924,6 +3455,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -2939,6 +3479,55 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3034,6 +3623,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -3514,6 +4124,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -3615,6 +4240,24 @@ "engines": { "node": ">= 12.0.0" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/server/package.json b/server/package.json index 103b706..4846160 100644 --- a/server/package.json +++ b/server/package.json @@ -7,6 +7,7 @@ "dev": "tsx watch src/index.ts" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.28.0", "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "better-sqlite3": "^12.8.0", @@ -17,14 +18,15 @@ "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", "node-cron": "^4.2.1", + "node-fetch": "^2.7.0", "otplib": "^12.0.1", "qrcode": "^1.5.4", - "node-fetch": "^2.7.0", "tsx": "^4.21.0", "typescript": "^6.0.2", "unzipper": "^0.12.3", "uuid": "^9.0.0", - "ws": "^8.19.0" + "ws": "^8.19.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/archiver": "^7.0.0", diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 816288b..6d47880 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -307,6 +307,35 @@ function runMigrations(db: Database.Database): void { db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7); } catch {} }, + // Migration 44: MCP long-lived API tokens + () => db.exec(` + CREATE TABLE IF NOT EXISTS mcp_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `), + // Migration 45: MCP addon entry + () => { + try { + db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'global', 'Terminal', 0, 12); + } catch {} + }, + // Migration 46: Index on mcp_tokens.token_hash for fast lookup + () => db.exec(` + CREATE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash) + `), + // Migration 47: Change MCP addon type from 'global' to 'integration' + () => { + try { + db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run(); + } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index e98b2f3..9c51ffc 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -33,6 +33,7 @@ function seedAddons(db: Database.Database): void { { id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 }, { id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 }, { id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 }, + { id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 }, { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); diff --git a/server/src/index.ts b/server/src/index.ts index c53977f..9cb17b7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -160,6 +160,10 @@ app.use('/api/weather', weatherRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/backup', backupRoutes); +// MCP endpoint (Streamable HTTP transport, per-user auth) +import { mcpHandler, closeMcpSessions } from './mcp'; +app.all('/mcp', mcpHandler); + // Serve static files in production if (process.env.NODE_ENV === 'production') { const publicPath = path.join(__dirname, '../public'); @@ -196,6 +200,7 @@ const server = app.listen(PORT, () => { function shutdown(signal: string): void { console.log(`\n${signal} received — shutting down gracefully...`); scheduler.stop(); + closeMcpSessions(); server.close(() => { console.log('HTTP server closed'); const { closeDb } = require('./db/database'); diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts new file mode 100644 index 0000000..dd21c81 --- /dev/null +++ b/server/src/mcp/index.ts @@ -0,0 +1,131 @@ +import { Request, Response } from 'express'; +import { randomUUID, createHash } from 'crypto'; +import jwt from 'jsonwebtoken'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp'; +import { JWT_SECRET } from '../config'; +import { db } from '../db/database'; +import { User } from '../types'; +import { registerResources } from './resources'; +import { registerTools } from './tools'; + +interface McpSession { + transport: StreamableHTTPServerTransport; + userId: number; + lastActivity: number; +} + +const sessions = new Map(); + +const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour + +const sessionSweepInterval = setInterval(() => { + const cutoff = Date.now() - SESSION_TTL_MS; + for (const [sid, session] of sessions) { + if (session.lastActivity < cutoff) { + try { session.transport.close(); } catch { /* ignore */ } + sessions.delete(sid); + } + } +}, 10 * 60 * 1000); // sweep every 10 minutes + +// Prevent the interval from keeping the process alive if nothing else is running +sessionSweepInterval.unref(); + +function verifyToken(authHeader: string | undefined): User | null { + const token = authHeader && authHeader.split(' ')[1]; + if (!token) return null; + + // Long-lived MCP API token (trek_...) + if (token.startsWith('trek_')) { + const hash = createHash('sha256').update(token).digest('hex'); + const row = db.prepare(` + SELECT u.id, u.username, u.email, u.role + FROM mcp_tokens mt + JOIN users u ON mt.user_id = u.id + WHERE mt.token_hash = ? + `).get(hash) as User | undefined; + if (row) { + // Update last_used_at (fire-and-forget, non-blocking) + db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); + return row; + } + return null; + } + + // Short-lived JWT + try { + const decoded = jwt.verify(token, JWT_SECRET) as { id: number }; + const user = db.prepare( + 'SELECT id, username, email, role FROM users WHERE id = ?' + ).get(decoded.id) as User | undefined; + return user || null; + } catch { + return null; + } +} + +export async function mcpHandler(req: Request, res: Response): Promise { + const mcpAddon = db.prepare("SELECT enabled FROM addons WHERE id = 'mcp'").get() as { enabled: number } | undefined; + if (!mcpAddon || !mcpAddon.enabled) { + res.status(403).json({ error: 'MCP is not enabled' }); + return; + } + + const user = verifyToken(req.headers['authorization']); + if (!user) { + res.status(401).json({ error: 'Access token required' }); + return; + } + + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + // Resume an existing session + if (sessionId) { + const session = sessions.get(sessionId); + if (!session) { + res.status(404).json({ error: 'Session not found' }); + return; + } + if (session.userId !== user.id) { + res.status(403).json({ error: 'Session belongs to a different user' }); + return; + } + session.lastActivity = Date.now(); + await session.transport.handleRequest(req, res, req.body); + return; + } + + // Only POST can initialize a new session + if (req.method !== 'POST') { + res.status(400).json({ error: 'Missing mcp-session-id header' }); + return; + } + + // Create a new per-user MCP server and session + const server = new McpServer({ name: 'trek', version: '1.0.0' }); + registerResources(server, user.id); + registerTools(server, user.id); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid) => { + sessions.set(sid, { transport, userId: user.id, lastActivity: Date.now() }); + }, + onsessionclosed: (sid) => { + sessions.delete(sid); + }, + }); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +} + +/** Close all active MCP sessions (call during graceful shutdown). */ +export function closeMcpSessions(): void { + clearInterval(sessionSweepInterval); + for (const [, session] of sessions) { + try { session.transport.close(); } catch { /* ignore */ } + } + sessions.clear(); +} diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts new file mode 100644 index 0000000..74d8813 --- /dev/null +++ b/server/src/mcp/resources.ts @@ -0,0 +1,299 @@ +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; +import { db, canAccessTrip } from '../db/database'; + +const TRIP_SELECT = ` + SELECT t.*, + (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count, + (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count, + CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner, + u.username as owner_username, + (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count + FROM trips t + JOIN users u ON u.id = t.user_id +`; + +function accessDenied(uri: string) { + return { + contents: [{ + uri, + mimeType: 'application/json', + text: JSON.stringify({ error: 'Trip not found or access denied' }), + }], + }; +} + +function jsonContent(uri: string, data: unknown) { + return { + contents: [{ + uri, + mimeType: 'application/json', + text: JSON.stringify(data, null, 2), + }], + }; +} + +export function registerResources(server: McpServer, userId: number): void { + // List all accessible trips + server.registerResource( + 'trips', + 'trek://trips', + { description: 'All trips the user owns or is a member of' }, + async (uri) => { + const trips = db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0 + ORDER BY t.created_at DESC + `).all({ userId }); + return jsonContent(uri.href, trips); + } + ); + + // Single trip detail + server.registerResource( + 'trip', + 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 trip = db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL) + `).get({ userId, tripId: id }); + return jsonContent(uri.href, trip); + } + ); + + // Days with assigned places + server.registerResource( + 'trip-days', + 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 days = db.prepare( + 'SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC' + ).all(id) as { id: number; day_number: number; date: string | null; title: string | null; notes: string | null }[]; + + const dayIds = days.map(d => d.id); + const assignmentsByDay: Record = {}; + + if (dayIds.length > 0) { + const placeholders = dayIds.map(() => '?').join(','); + const assignments = db.prepare(` + SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes, + p.id as place_id, p.name, p.address, p.lat, p.lng, p.category_id, + COALESCE(da.assignment_time, p.place_time) as place_time, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${placeholders}) + ORDER BY da.order_index ASC, da.created_at ASC + `).all(...dayIds) as (Record & { day_id: number })[]; + + for (const a of assignments) { + if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = []; + assignmentsByDay[a.day_id].push(a); + } + } + + const result = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] })); + return jsonContent(uri.href, result); + } + ); + + // Places in a trip + server.registerResource( + 'trip-places', + 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 places = db.prepare(` + SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon + FROM places p + LEFT JOIN categories c ON p.category_id = c.id + WHERE p.trip_id = ? + ORDER BY p.created_at DESC + `).all(id); + return jsonContent(uri.href, places); + } + ); + + // Budget items + server.registerResource( + 'trip-budget', + 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 items = db.prepare( + 'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC' + ).all(id); + return jsonContent(uri.href, items); + } + ); + + // Packing checklist + server.registerResource( + 'trip-packing', + 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 items = db.prepare( + 'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC' + ).all(id); + return jsonContent(uri.href, items); + } + ); + + // Reservations (flights, hotels, restaurants) + server.registerResource( + 'trip-reservations', + 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 reservations = db.prepare(` + SELECT r.*, d.day_number, p.name as place_name + FROM reservations r + LEFT JOIN days d ON r.day_id = d.id + LEFT JOIN places p ON r.place_id = p.id + WHERE r.trip_id = ? + ORDER BY r.reservation_time ASC, r.created_at ASC + `).all(id); + return jsonContent(uri.href, reservations); + } + ); + + // Day notes + server.registerResource( + 'day-notes', + 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 notes = db.prepare( + 'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC' + ).all(dId, tId); + return jsonContent(uri.href, notes); + } + ); + + // Accommodations (hotels, rentals) per trip + server.registerResource( + 'trip-accommodations', + 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 accommodations = db.prepare(` + SELECT da.*, p.name as place_name, p.address as place_address, p.lat, p.lng, + ds.day_number as start_day_number, de.day_number as end_day_number + FROM day_accommodations da + JOIN places p ON da.place_id = p.id + LEFT JOIN days ds ON da.start_day_id = ds.id + LEFT JOIN days de ON da.end_day_id = de.id + WHERE da.trip_id = ? + ORDER BY ds.day_number ASC + `).all(id); + return jsonContent(uri.href, accommodations); + } + ); + + // Trip members (owner + collaborators) + server.registerResource( + 'trip-members', + 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 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; + const members = db.prepare(` + SELECT u.id, u.username, u.avatar, tm.added_at + FROM trip_members tm + JOIN users u ON tm.user_id = u.id + WHERE tm.trip_id = ? + ORDER BY tm.added_at ASC + `).all(id); + return jsonContent(uri.href, { + owner: owner ? { ...owner, role: 'owner' } : null, + members, + }); + } + ); + + // Collab notes for a trip + server.registerResource( + 'trip-collab-notes', + 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 notes = db.prepare(` + SELECT cn.*, u.username + FROM collab_notes cn + JOIN users u ON cn.user_id = u.id + WHERE cn.trip_id = ? + ORDER BY cn.pinned DESC, cn.updated_at DESC + `).all(id); + return jsonContent(uri.href, notes); + } + ); + + // All place categories (global, no trip filter) + server.registerResource( + 'categories', + 'trek://categories', + { description: 'All available place categories (id, name, color, icon) for use when creating places' }, + async (uri) => { + const categories = db.prepare( + 'SELECT id, name, color, icon FROM categories ORDER BY name ASC' + ).all(); + return jsonContent(uri.href, categories); + } + ); + + // User's bucket list + server.registerResource( + 'bucket-list', + 'trek://bucket-list', + { description: 'Your personal travel bucket list' }, + async (uri) => { + const items = db.prepare( + 'SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId); + return jsonContent(uri.href, items); + } + ); + + // User's visited countries + server.registerResource( + 'visited-countries', + 'trek://visited-countries', + { description: 'Countries you have marked as visited in Atlas' }, + async (uri) => { + const countries = db.prepare( + 'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId); + return jsonContent(uri.href, countries); + } + ); +} diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts new file mode 100644 index 0000000..d73701c --- /dev/null +++ b/server/src/mcp/tools.ts @@ -0,0 +1,881 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { db, canAccessTrip, isOwner } from '../db/database'; +import { broadcast } from '../websocket'; + +const MS_PER_DAY = 86400000; +const MAX_TRIP_DAYS = 90; + +function isDemoUser(userId: number): boolean { + if (process.env.DEMO_MODE !== 'true') return false; + const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined; + return user?.email === 'demo@nomad.app'; +} + +function demoDenied() { + return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true }; +} + +function noAccess() { + return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true }; +} + +function ok(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; +} + +/** Create days for a newly created trip (fresh insert, no existing days). */ +function createDaysForNewTrip(tripId: number | bigint, startDate: string | null, endDate: string | null): void { + const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); + if (startDate && endDate) { + const [sy, sm, sd] = startDate.split('-').map(Number); + const [ey, em, ed] = endDate.split('-').map(Number); + const startMs = Date.UTC(sy, sm - 1, sd); + const endMs = Date.UTC(ey, em - 1, ed); + const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS); + for (let i = 0; i < numDays; i++) { + const d = new Date(startMs + i * MS_PER_DAY); + const date = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; + insert.run(tripId, i + 1, date); + } + } else { + for (let i = 0; i < 7; i++) insert.run(tripId, i + 1, null); + } +} + +export function registerTools(server: McpServer, userId: number): void { + // --- TRIPS --- + + server.registerTool( + 'create_trip', + { + description: 'Create a new trip. Returns the created trip with its generated days.', + inputSchema: { + title: z.string().min(1).max(200).describe('Trip title'), + description: z.string().max(2000).optional().describe('Trip description'), + start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'), + end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'), + currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'), + }, + }, + async ({ title, description, start_date, end_date, currency }) => { + if (isDemoUser(userId)) return demoDenied(); + 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); + return ok({ trip }); + } + ); + + server.registerTool( + 'update_trip', + { + description: 'Update an existing trip\'s details.', + inputSchema: { + tripId: z.number().int().positive(), + title: z.string().min(1).max(200).optional(), + description: z.string().max(2000).optional(), + start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + currency: z.string().length(3).optional(), + }, + }, + async ({ tripId, title, description, start_date, end_date, currency }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + 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( + 'UPDATE trips SET title = ?, description = ?, start_date = ?, end_date = ?, currency = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' + ).run( + title ?? existing.title, + description !== undefined ? description : existing.description, + start_date !== undefined ? start_date : existing.start_date, + end_date !== undefined ? end_date : existing.end_date, + currency ?? existing.currency, + tripId + ); + const updated = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId); + broadcast(tripId, 'trip:updated', { trip: updated }); + return ok({ trip: updated }); + } + ); + + server.registerTool( + 'delete_trip', + { + description: 'Delete a trip. Only the trip owner can delete it.', + inputSchema: { + tripId: z.number().int().positive(), + }, + }, + async ({ tripId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!isOwner(tripId, userId)) return noAccess(); + db.prepare('DELETE FROM trips WHERE id = ?').run(tripId); + return ok({ success: true, tripId }); + } + ); + + // --- PLACES --- + + server.registerTool( + 'create_place', + { + description: 'Add a new place/POI to a trip.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + lat: z.number().optional(), + lng: z.number().optional(), + address: z.string().max(500).optional(), + category_id: z.number().int().positive().optional(), + notes: z.string().max(2000).optional(), + website: z.string().max(500).optional(), + phone: z.string().max(50).optional(), + }, + }, + async ({ tripId, name, description, lat, lng, address, category_id, notes, website, phone }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const result = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, notes, website, phone, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(tripId, name, description || null, lat ?? null, lng ?? null, address || null, category_id || null, notes || null, website || null, phone || null, 'walking'); + const place = db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid); + broadcast(tripId, 'place:created', { place }); + return ok({ place }); + } + ); + + server.registerTool( + 'update_place', + { + description: 'Update an existing place in a trip.', + inputSchema: { + tripId: z.number().int().positive(), + placeId: z.number().int().positive(), + name: z.string().min(1).max(200).optional(), + description: z.string().max(2000).optional(), + lat: z.number().optional(), + lng: z.number().optional(), + address: z.string().max(500).optional(), + notes: z.string().max(2000).optional(), + website: z.string().max(500).optional(), + phone: z.string().max(50).optional(), + }, + }, + async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const existing = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record | undefined; + if (!existing) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; + db.prepare(` + UPDATE places SET + name = ?, description = ?, lat = ?, lng = ?, address = ?, notes = ?, website = ?, phone = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + name ?? existing.name, + description !== undefined ? description : existing.description, + lat !== undefined ? lat : existing.lat, + lng !== undefined ? lng : existing.lng, + address !== undefined ? address : existing.address, + notes !== undefined ? notes : existing.notes, + website !== undefined ? website : existing.website, + phone !== undefined ? phone : existing.phone, + placeId + ); + const place = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId); + broadcast(tripId, 'place:updated', { place }); + return ok({ place }); + } + ); + + server.registerTool( + 'delete_place', + { + description: 'Delete a place from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + placeId: z.number().int().positive(), + }, + }, + async ({ tripId, placeId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); + if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; + db.prepare('DELETE FROM places WHERE id = ?').run(placeId); + broadcast(tripId, 'place:deleted', { placeId }); + return ok({ success: true }); + } + ); + + // --- ASSIGNMENTS --- + + server.registerTool( + 'assign_place_to_day', + { + description: 'Assign a place to a specific day in a trip.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + placeId: z.number().int().positive(), + notes: z.string().max(500).optional(), + }, + }, + async ({ tripId, dayId, placeId, notes }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); + if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); + if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; + const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null }; + const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + const result = db.prepare( + 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)' + ).run(dayId, placeId, orderIndex, notes || null); + const assignment = db.prepare(` + SELECT da.*, p.name as place_name, p.address, p.lat, p.lng + FROM day_assignments da JOIN places p ON da.place_id = p.id + WHERE da.id = ? + `).get(result.lastInsertRowid); + broadcast(tripId, 'assignment:created', { assignment }); + return ok({ assignment }); + } + ); + + server.registerTool( + 'unassign_place', + { + description: 'Remove a place assignment from a day.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + assignmentId: z.number().int().positive(), + }, + }, + async ({ tripId, dayId, assignmentId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const assignment = db.prepare( + 'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?' + ).get(assignmentId, dayId, tripId); + if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; + db.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignmentId); + broadcast(tripId, 'assignment:deleted', { assignmentId, dayId }); + return ok({ success: true }); + } + ); + + // --- BUDGET --- + + server.registerTool( + 'create_budget_item', + { + description: 'Add a budget/expense item to a trip.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(200), + category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'), + total_price: z.number().nonnegative(), + note: z.string().max(500).optional(), + }, + }, + async ({ tripId, name, category, total_price, note }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as { max: number | null }; + const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + const result = db.prepare( + 'INSERT INTO budget_items (trip_id, category, name, total_price, note, sort_order) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, category || 'Other', name, total_price, note || null, sortOrder); + const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid); + broadcast(tripId, 'budget:created', { item }); + return ok({ item }); + } + ); + + server.registerTool( + 'delete_budget_item', + { + description: 'Delete a budget item from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + }, + }, + async ({ tripId, itemId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId); + if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; + db.prepare('DELETE FROM budget_items WHERE id = ?').run(itemId); + broadcast(tripId, 'budget:deleted', { itemId }); + return ok({ success: true }); + } + ); + + // --- PACKING --- + + server.registerTool( + 'create_packing_item', + { + description: 'Add an item to the packing checklist for a trip.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(200), + category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'), + }, + }, + async ({ tripId, name, category }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null }; + const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + const result = db.prepare( + 'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, name, 0, category || 'General', sortOrder); + const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); + broadcast(tripId, 'packing:created', { item }); + return ok({ item }); + } + ); + + server.registerTool( + 'toggle_packing_item', + { + description: 'Check or uncheck a packing item.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + checked: z.boolean(), + }, + }, + async ({ tripId, itemId, checked }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId); + if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; + db.prepare('UPDATE packing_items SET checked = ? WHERE id = ?').run(checked ? 1 : 0, itemId); + const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId); + broadcast(tripId, 'packing:updated', { item: updated }); + return ok({ item: updated }); + } + ); + + server.registerTool( + 'delete_packing_item', + { + description: 'Remove an item from the packing checklist.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + }, + }, + async ({ tripId, itemId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId); + if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; + db.prepare('DELETE FROM packing_items WHERE id = ?').run(itemId); + broadcast(tripId, 'packing:deleted', { itemId }); + return ok({ success: true }); + } + ); + + // --- RESERVATIONS --- + + server.registerTool( + 'create_reservation', + { + description: 'Add a reservation (flight, hotel, restaurant, etc.) to a trip.', + inputSchema: { + tripId: z.number().int().positive(), + title: z.string().min(1).max(200), + type: z.enum(['flight', 'hotel', 'restaurant', '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(), + }, + }, + async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_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); + broadcast(tripId, 'reservation:created', { reservation }); + return ok({ reservation }); + } + ); + + server.registerTool( + 'delete_reservation', + { + description: 'Delete a reservation from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + reservationId: z.number().int().positive(), + }, + }, + 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); + broadcast(tripId, 'reservation:deleted', { reservationId }); + return ok({ success: true }); + } + ); + + // --- DAYS --- + + server.registerTool( + 'update_day', + { + description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + title: z.string().max(200).nullable().describe('Day title, or null to clear it'), + }, + }, + async ({ tripId, dayId, title }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); + if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + db.prepare('UPDATE days SET title = ? WHERE id = ?').run(title, dayId); + const updated = db.prepare('SELECT * FROM days WHERE id = ?').get(dayId); + broadcast(tripId, 'day:updated', { day: updated }); + return ok({ day: updated }); + } + ); + + // --- RESERVATIONS (update) --- + + server.registerTool( + 'update_reservation', + { + description: 'Update an existing reservation in a trip.', + 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(), + 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(), + }, + }, + async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status }) => { + 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 }; + db.prepare(` + UPDATE reservations SET + title = ?, type = ?, reservation_time = ?, location = ?, + confirmation_number = ?, notes = ?, status = ? + WHERE id = ? + `).run( + title ?? existing.title, + type ?? existing.type, + reservation_time !== undefined ? reservation_time : existing.reservation_time, + location !== undefined ? location : existing.location, + confirmation_number !== undefined ? confirmation_number : existing.confirmation_number, + notes !== undefined ? notes : existing.notes, + status ?? existing.status, + reservationId + ); + const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId); + broadcast(tripId, 'reservation:updated', { reservation: updated }); + return ok({ reservation: updated }); + } + ); + + // --- BUDGET (update) --- + + server.registerTool( + 'update_budget_item', + { + description: 'Update an existing budget/expense item in a trip.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + name: z.string().min(1).max(200).optional(), + category: z.string().max(100).optional(), + total_price: z.number().nonnegative().optional(), + persons: z.number().int().positive().nullable().optional(), + days: z.number().int().positive().nullable().optional(), + note: z.string().max(500).nullable().optional(), + }, + }, + async ({ tripId, itemId, name, category, total_price, persons, days, note }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const existing = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record | undefined; + if (!existing) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; + db.prepare(` + UPDATE budget_items SET + name = ?, category = ?, total_price = ?, persons = ?, days = ?, note = ? + WHERE id = ? + `).run( + name ?? existing.name, + category ?? existing.category, + total_price !== undefined ? total_price : existing.total_price, + persons !== undefined ? persons : existing.persons, + days !== undefined ? days : existing.days, + note !== undefined ? note : existing.note, + itemId + ); + const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId); + broadcast(tripId, 'budget:updated', { item: updated }); + return ok({ item: updated }); + } + ); + + // --- PACKING (update) --- + + server.registerTool( + 'update_packing_item', + { + description: 'Rename a packing item or change its category.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + name: z.string().min(1).max(200).optional(), + category: z.string().max(100).optional(), + }, + }, + async ({ tripId, itemId, name, category }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const existing = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record | undefined; + if (!existing) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; + db.prepare('UPDATE packing_items SET name = ?, category = ? WHERE id = ?').run( + name ?? existing.name, + category ?? existing.category, + itemId + ); + const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId); + broadcast(tripId, 'packing:updated', { item: updated }); + return ok({ item: updated }); + } + ); + + // --- REORDER --- + + server.registerTool( + 'reorder_day_assignments', + { + description: 'Reorder places within a day by providing the assignment IDs in the desired order.', + 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'), + }, + }, + async ({ tripId, dayId, assignmentIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); + if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?'); + const updateMany = db.transaction((ids: number[]) => { + ids.forEach((id, index) => update.run(index, id, dayId)); + }); + updateMany(assignmentIds); + broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds }); + return ok({ success: true, dayId, order: assignmentIds }); + } + ); + + // --- TRIP SUMMARY --- + + 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.', + inputSchema: { + tripId: z.number().int().positive(), + }, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + + const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined; + if (!trip) return noAccess(); + + // Members + const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id as number); + const members = db.prepare(` + SELECT u.id, u.username, u.avatar, tm.added_at + FROM trip_members tm JOIN users u ON tm.user_id = u.id + WHERE tm.trip_id = ? + `).all(tripId); + + // Days with assignments + const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as (Record & { id: number })[]; + const dayIds = days.map(d => d.id); + const assignmentsByDay: Record = {}; + if (dayIds.length > 0) { + const placeholders = dayIds.map(() => '?').join(','); + const assignments = db.prepare(` + SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes, + p.id as place_id, p.name, p.address, p.lat, p.lng, + COALESCE(da.assignment_time, p.place_time) as place_time, + c.name as category_name, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${placeholders}) + ORDER BY da.order_index ASC + `).all(...dayIds) as (Record & { day_id: number })[]; + for (const a of assignments) { + if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = []; + assignmentsByDay[a.day_id].push(a); + } + } + const daysWithAssignments = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] })); + + // Accommodations + const accommodations = db.prepare(` + SELECT da.*, p.name as place_name, ds.day_number as start_day_number, de.day_number as end_day_number + FROM day_accommodations da + JOIN places p ON da.place_id = p.id + LEFT JOIN days ds ON da.start_day_id = ds.id + LEFT JOIN days de ON da.end_day_id = de.id + WHERE da.trip_id = ? + ORDER BY ds.day_number ASC + `).all(tripId); + + // Budget summary + const budgetStats = db.prepare(` + SELECT COUNT(*) as item_count, COALESCE(SUM(total_price), 0) as total + FROM budget_items WHERE trip_id = ? + `).get(tripId) as { item_count: number; total: number }; + + // Packing summary + const packingStats = db.prepare(` + SELECT COUNT(*) as total, SUM(CASE WHEN checked = 1 THEN 1 ELSE 0 END) as checked + FROM packing_items WHERE trip_id = ? + `).get(tripId) as { total: number; checked: number }; + + // Upcoming reservations (all, sorted by time) + const reservations = db.prepare(` + SELECT r.*, d.day_number + FROM reservations r + LEFT JOIN days d ON r.day_id = d.id + WHERE r.trip_id = ? + ORDER BY r.reservation_time ASC, r.created_at ASC + `).all(tripId); + + return ok({ + trip, + members: { owner, collaborators: members }, + days: daysWithAssignments, + accommodations, + budget: { ...budgetStats, currency: trip.currency }, + packing: packingStats, + reservations, + }); + } + ); + + // --- BUCKET LIST --- + + server.registerTool( + 'create_bucket_list_item', + { + description: 'Add a destination to your personal travel bucket list.', + inputSchema: { + name: z.string().min(1).max(200).describe('Destination or experience name'), + lat: z.number().optional(), + lng: z.number().optional(), + country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'), + notes: z.string().max(1000).optional(), + }, + }, + async ({ name, lat, lng, country_code, notes }) => { + if (isDemoUser(userId)) return demoDenied(); + const result = db.prepare( + 'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)' + ).run(userId, name, lat ?? null, lng ?? null, country_code || null, notes || null); + const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid); + return ok({ item }); + } + ); + + server.registerTool( + 'delete_bucket_list_item', + { + description: 'Remove an item from your travel bucket list.', + inputSchema: { + itemId: z.number().int().positive(), + }, + }, + async ({ itemId }) => { + if (isDemoUser(userId)) return demoDenied(); + const item = db.prepare('SELECT id FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId); + if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true }; + db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId); + return ok({ success: true }); + } + ); + + // --- ATLAS --- + + server.registerTool( + 'mark_country_visited', + { + description: 'Mark a country as visited in your Atlas.', + inputSchema: { + country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'), + }, + }, + async ({ country_code }) => { + if (isDemoUser(userId)) return demoDenied(); + db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, country_code.toUpperCase()); + return ok({ success: true, country_code: country_code.toUpperCase() }); + } + ); + + server.registerTool( + 'unmark_country_visited', + { + description: 'Remove a country from your visited countries in Atlas.', + inputSchema: { + country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'), + }, + }, + async ({ country_code }) => { + if (isDemoUser(userId)) return demoDenied(); + db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, country_code.toUpperCase()); + return ok({ success: true, country_code: country_code.toUpperCase() }); + } + ); + + // --- COLLAB NOTES --- + + server.registerTool( + 'create_collab_note', + { + description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).', + inputSchema: { + tripId: z.number().int().positive(), + title: z.string().min(1).max(200), + content: z.string().max(10000).optional(), + category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), + }, + }, + async ({ tripId, title, content, category, color }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const result = db.prepare(` + INSERT INTO collab_notes (trip_id, user_id, title, content, category, color) + VALUES (?, ?, ?, ?, ?, ?) + `).run(tripId, userId, title, content || null, category || 'General', color || '#6366f1'); + const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid); + broadcast(tripId, 'collab:note:created', { note }); + return ok({ note }); + } + ); + + // --- DAY NOTES --- + + server.registerTool( + 'create_day_note', + { + description: 'Add a note to a specific day in a trip.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + text: z.string().min(1).max(500), + time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'), + icon: z.string().optional().describe('Emoji icon for the note'), + }, + }, + async ({ tripId, dayId, text, time, icon }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); + if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const result = db.prepare( + 'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)' + ).run(dayId, tripId, text.trim(), time || null, icon || '📝', 9999); + const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid); + broadcast(tripId, 'dayNote:created', { dayId, note }); + return ok({ note }); + } + ); + + server.registerTool( + 'update_day_note', + { + description: 'Edit an existing note on a specific day.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + noteId: z.number().int().positive(), + text: z.string().min(1).max(500).optional(), + time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'), + icon: z.string().optional().describe('Emoji icon for the note'), + }, + }, + async ({ tripId, dayId, noteId, text, time, icon }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const existing = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId) as Record | undefined; + if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; + db.prepare('UPDATE day_notes SET text = ?, time = ?, icon = ? WHERE id = ?').run( + text !== undefined ? text.trim() : existing.text, + time !== undefined ? time : existing.time, + icon ?? existing.icon, + noteId + ); + const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(noteId); + broadcast(tripId, 'dayNote:updated', { dayId, note: updated }); + return ok({ note: updated }); + } + ); + + server.registerTool( + 'delete_day_note', + { + description: 'Delete a note from a specific day.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + noteId: z.number().int().positive(), + }, + }, + async ({ tripId, dayId, noteId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId); + if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; + db.prepare('DELETE FROM day_notes WHERE id = ?').run(noteId); + broadcast(tripId, 'dayNote:deleted', { noteId, dayId }); + return ok({ success: true }); + } + ); +} diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index c7aaf2a..ca4f30c 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -411,4 +411,21 @@ router.put('/addons/:id', (req: Request, res: Response) => { res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } }); }); +router.get('/mcp-tokens', (req: Request, res: Response) => { + const tokens = db.prepare(` + SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username + FROM mcp_tokens t + JOIN users u ON u.id = t.user_id + ORDER BY t.created_at DESC + `).all(); + res.json({ tokens }); +}); + +router.delete('/mcp-tokens/:id', (req: Request, res: Response) => { + const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(req.params.id); + if (!token) return res.status(404).json({ error: 'Token not found' }); + db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + export default router; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index b5518ef..7b956f0 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -12,6 +12,7 @@ import { db } from '../db/database'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { JWT_SECRET } from '../config'; import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; +import { randomBytes, createHash } from 'crypto'; import { AuthRequest, User } from '../types'; authenticator.options = { window: 1 }; @@ -705,4 +706,46 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re res.json({ success: true, mfa_enabled: false }); }); +// --- MCP Token Management --- + +router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const tokens = db.prepare( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC' + ).all(authReq.user.id); + res.json({ tokens }); +}); + +router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { name } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Token name is required' }); + + 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' }); + + const rawToken = 'trek_' + randomBytes(24).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const tokenPrefix = rawToken.slice(0, 13); // "trek_" + 8 hex chars + + const result = db.prepare( + 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)' + ).run(authReq.user.id, name.trim(), tokenHash, tokenPrefix); + + const token = db.prepare( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?' + ).get(result.lastInsertRowid); + + res.status(201).json({ token: { ...(token as object), raw_token: rawToken } }); +}); + +router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { id } = req.params; + 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); + res.json({ success: true }); +}); + export default router; diff --git a/server/tsconfig.json b/server/tsconfig.json index 0ecff37..25e99aa 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -9,12 +9,20 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", "resolveJsonModule": true, "declaration": false, "sourceMap": true, "allowJs": true, "noUnusedLocals": false, - "noUnusedParameters": false + "noUnusedParameters": false, + // The MCP SDK's package.json uses a wildcard exports pattern with extension-less targets + // (e.g. "./*": "./dist/esm/*") which TypeScript cannot resolve — it only strips .js suffixes. + // These paths manually redirect to the CJS dist until the SDK fixes its exports map. + "paths": { + "@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"], + "@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"] + } }, "include": ["src"], "exclude": ["node_modules", "dist"]