fix: stabilize post-MVP travel-agent and itinerary workflows

This commit is contained in:
2026-03-08 16:51:19 +00:00
parent fb2347345f
commit 2fd11dbd26
27 changed files with 2533 additions and 794 deletions

View File

@@ -29,6 +29,9 @@ BACKEND_PORT=8016
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# FIELD_ENCRYPTION_KEY=replace_with_fernet_key
# Optional: custom MCP HTTP endpoint path (default: api/mcp)
# DJANGO_MCP_ENDPOINT=api/mcp
# Optional: disable registration
# https://adventurelog.app/docs/configuration/disable_registration.html
DISABLE_REGISTRATION=False

View File

@@ -108,6 +108,14 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
### Travel Agent (MCP)
Voyage provides an authenticated Travel Agent MCP endpoint for programmatic itinerary workflows (list collections, inspect itinerary details, create items, reorder timelines). See the guide: [`documentation/docs/guides/travel_agent.md`](documentation/docs/guides/travel_agent.md).
- Default MCP path: `api/mcp`
- Override MCP path with env var: `DJANGO_MCP_ENDPOINT`
- Get token from authenticated session: `GET /auth/mcp-token/` and use header `Authorization: Token <token>`
<!-- Roadmap -->
## 🧭 Roadmap
@@ -126,6 +134,22 @@ Contributions are always welcome!
See `contributing.md` for ways to get started.
### Pre-upgrade backup
Before upgrading Voyage or running migrations, export a collections backup snapshot:
```bash
docker compose exec server python manage.py export_collections_backup
```
Optional custom output path:
```bash
docker compose exec server python manage.py export_collections_backup --output /code/backups/collections_backup_pre_upgrade.json
```
This command exports `Collection` and `CollectionItineraryItem` data with timestamp and counts.
### Translation
Voyage is available on [Weblate](https://hosted.weblate.org/projects/voyage/). If you would like to help translate Voyage into your language, please visit the link and contribute!

View File

@@ -27,6 +27,7 @@ EMAIL_BACKEND='console'
# GOOGLE_MAPS_API_KEY='key'
# OSRM_BASE_URL='https://router.project-osrm.org' # replace with self-host URL if needed (e.g. http://osrm:5000)
# DJANGO_MCP_ENDPOINT='api/mcp' # optional custom MCP HTTP endpoint path
# ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment

View File

@@ -0,0 +1,104 @@
import json
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from adventures.models import Collection, CollectionItineraryItem
class Command(BaseCommand):
help = (
"Export Collection and CollectionItineraryItem data to a JSON backup "
"file before upgrades/migrations."
)
def add_arguments(self, parser):
parser.add_argument(
"--output",
type=str,
help="Optional output file path (default: ./collections_backup_<timestamp>.json)",
)
def handle(self, *args, **options):
backup_timestamp = timezone.now()
timestamp = backup_timestamp.strftime("%Y%m%d_%H%M%S")
output_path = Path(
options.get("output") or f"collections_backup_{timestamp}.json"
)
if output_path.parent and not output_path.parent.exists():
raise CommandError(f"Output directory does not exist: {output_path.parent}")
collections = list(
Collection.objects.values(
"id",
"user_id",
"name",
"description",
"is_public",
"is_archived",
"start_date",
"end_date",
"link",
"primary_image_id",
"created_at",
"updated_at",
)
)
shared_with_map = {
str(collection.id): list(
collection.shared_with.values_list("id", flat=True)
)
for collection in Collection.objects.prefetch_related("shared_with")
}
for collection in collections:
collection["shared_with_ids"] = shared_with_map.get(
str(collection["id"]), []
)
itinerary_items = list(
CollectionItineraryItem.objects.select_related("content_type").values(
"id",
"collection_id",
"content_type_id",
"content_type__app_label",
"content_type__model",
"object_id",
"date",
"is_global",
"order",
"created_at",
)
)
backup_payload = {
"backup_type": "collections_snapshot",
"timestamp": backup_timestamp.isoformat(),
"counts": {
"collections": len(collections),
"collection_itinerary_items": len(itinerary_items),
},
"collections": collections,
"collection_itinerary_items": itinerary_items,
}
try:
with output_path.open("w", encoding="utf-8") as backup_file:
json.dump(backup_payload, backup_file, indent=2, cls=DjangoJSONEncoder)
except OSError as exc:
raise CommandError(f"Failed to write backup file: {exc}") from exc
except (TypeError, ValueError) as exc:
raise CommandError(f"Failed to serialize backup data: {exc}") from exc
self.stdout.write(
self.style.SUCCESS(
"Exported collections backup to "
f"{output_path} "
f"at {backup_timestamp.isoformat()} "
f"(collections: {len(collections)}, "
f"itinerary_items: {len(itinerary_items)})."
)
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,34 @@
import json
import tempfile
import base64
from datetime import timedelta
from pathlib import Path
from unittest.mock import Mock, patch
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient, APITestCase
from adventures.models import (
Collection,
CollectionItineraryItem,
ContentImage,
Lodging,
Note,
Transportation,
)
User = get_user_model()
class WeatherEndpointTests(APITestCase):
class WeatherViewTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
username="weather-user",
@@ -35,11 +53,38 @@ class WeatherEndpointTests(APITestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("maximum", response.json().get("error", "").lower())
@patch("adventures.views.weather_view.requests.get")
def test_daily_temperatures_future_date_returns_unavailable_without_external_call(
self, mock_requests_get
@patch("adventures.views.weather_view.WeatherViewSet._fetch_daily_temperature")
def test_daily_temperatures_future_date_reaches_fetch_path(
self, mock_fetch_temperature
):
future_date = (timezone.now().date() + timedelta(days=10)).isoformat()
mock_fetch_temperature.return_value = {
"date": future_date,
"available": True,
"temperature_c": 22.5,
}
response = self.client.post(
"/api/weather/daily-temperatures/",
{"days": [{"date": future_date, "latitude": 12.34, "longitude": 56.78}]},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["results"][0]["date"], future_date)
self.assertTrue(response.json()["results"][0]["available"])
self.assertEqual(response.json()["results"][0]["temperature_c"], 22.5)
mock_fetch_temperature.assert_called_once_with(future_date, 12.34, 56.78)
@patch("adventures.views.weather_view.requests.get")
def test_daily_temperatures_far_future_returns_unavailable_when_upstream_has_no_data(
self, mock_requests_get
):
future_date = (timezone.now().date() + timedelta(days=3650)).isoformat()
mocked_response = Mock()
mocked_response.raise_for_status.return_value = None
mocked_response.json.return_value = {"daily": {}}
mock_requests_get.return_value = mocked_response
response = self.client.post(
"/api/weather/daily-temperatures/",
@@ -52,7 +97,7 @@ class WeatherEndpointTests(APITestCase):
response.json()["results"][0],
{"date": future_date, "available": False, "temperature_c": None},
)
mock_requests_get.assert_not_called()
self.assertEqual(mock_requests_get.call_count, 2)
@patch("adventures.views.weather_view.requests.get")
def test_daily_temperatures_accepts_zero_lat_lon(self, mock_requests_get):
@@ -106,3 +151,166 @@ class MCPAuthTests(APITestCase):
unauthenticated_client = APIClient()
response = unauthenticated_client.post("/api/mcp", {}, format="json")
self.assertIn(response.status_code, [401, 403])
class CollectionViewSetTests(APITestCase):
def setUp(self):
self.owner = User.objects.create_user(
username="collection-owner",
email="owner@example.com",
password="password123",
)
self.shared_user = User.objects.create_user(
username="collection-shared",
email="shared@example.com",
password="password123",
)
def _create_test_image_file(self, name="test.png"):
# 1x1 PNG
png_bytes = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Y9x8AAAAASUVORK5CYII="
)
return SimpleUploadedFile(name, png_bytes, content_type="image/png")
def _create_collection_with_non_location_images(self):
collection = Collection.objects.create(
user=self.owner,
name="Image fallback collection",
)
lodging = Lodging.objects.create(
user=self.owner,
collection=collection,
name="Fallback lodge",
)
transportation = Transportation.objects.create(
user=self.owner,
collection=collection,
type="car",
name="Fallback ride",
)
lodging_content_type = ContentType.objects.get_for_model(Lodging)
transportation_content_type = ContentType.objects.get_for_model(Transportation)
ContentImage.objects.create(
user=self.owner,
content_type=lodging_content_type,
object_id=lodging.id,
image=self._create_test_image_file("lodging.png"),
is_primary=True,
)
ContentImage.objects.create(
user=self.owner,
content_type=transportation_content_type,
object_id=transportation.id,
image=self._create_test_image_file("transport.png"),
is_primary=True,
)
return collection
def test_list_includes_lodging_transportation_images_when_no_location_images(self):
collection = self._create_collection_with_non_location_images()
self.client.force_authenticate(user=self.owner)
response = self.client.get("/api/collections/")
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data.get("results", [])), 0)
collection_payload = next(
item
for item in response.data["results"]
if item["id"] == str(collection.id)
)
self.assertIn("location_images", collection_payload)
self.assertGreater(len(collection_payload["location_images"]), 0)
self.assertTrue(
any(
image.get("is_primary")
for image in collection_payload["location_images"]
)
)
def test_shared_endpoint_includes_non_location_primary_images(self):
collection = self._create_collection_with_non_location_images()
collection.shared_with.add(self.shared_user)
self.client.force_authenticate(user=self.shared_user)
response = self.client.get("/api/collections/shared/")
self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 0)
collection_payload = next(
item for item in response.data if item["id"] == str(collection.id)
)
self.assertEqual(str(collection.id), collection_payload["id"])
self.assertIn("location_images", collection_payload)
self.assertGreater(len(collection_payload["location_images"]), 0)
first_image = collection_payload["location_images"][0]
self.assertSetEqual(
set(first_image.keys()),
{"id", "image", "is_primary", "user", "immich_id"},
)
class ExportCollectionsBackupCommandTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="backup-user",
email="backup@example.com",
password="password123",
)
self.collaborator = User.objects.create_user(
username="collab-user",
email="collab@example.com",
password="password123",
)
self.collection = Collection.objects.create(
user=self.user,
name="My Trip",
description="Backup test collection",
)
self.collection.shared_with.add(self.collaborator)
note = Note.objects.create(user=self.user, name="Test item")
note_content_type = ContentType.objects.get_for_model(Note)
CollectionItineraryItem.objects.create(
collection=self.collection,
content_type=note_content_type,
object_id=note.id,
date=timezone.now().date(),
is_global=False,
order=1,
)
def test_export_collections_backup_writes_expected_payload(self):
with tempfile.TemporaryDirectory() as temp_dir:
output_file = Path(temp_dir) / "collections_snapshot.json"
call_command("export_collections_backup", output=str(output_file))
self.assertTrue(output_file.exists())
payload = json.loads(output_file.read_text(encoding="utf-8"))
self.assertEqual(payload["backup_type"], "collections_snapshot")
self.assertIn("timestamp", payload)
self.assertEqual(payload["counts"]["collections"], 1)
self.assertEqual(payload["counts"]["collection_itinerary_items"], 1)
self.assertEqual(len(payload["collections"]), 1)
self.assertEqual(len(payload["collection_itinerary_items"]), 1)
self.assertEqual(
payload["collections"][0]["shared_with_ids"],
[self.collaborator.id],
)
def test_export_collections_backup_raises_for_missing_output_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
missing_directory = Path(temp_dir) / "missing"
output_file = missing_directory / "collections_snapshot.json"
with self.assertRaises(CommandError):
call_command("export_collections_backup", output=str(output_file))

File diff suppressed because it is too large Load Diff

View File

@@ -60,12 +60,6 @@ class WeatherViewSet(viewsets.ViewSet):
)
continue
if parsed_date > date_cls.today():
results.append(
{"date": date, "available": False, "temperature_c": None}
)
continue
try:
lat = float(latitude)
lon = float(longitude)

View File

@@ -1,3 +1,5 @@
from django.db import IntegrityError
from .models import (
EncryptionConfigurationError,
ImmichIntegration,
@@ -41,12 +43,28 @@ class UserAPIKeySerializer(serializers.ModelSerializer):
def create(self, validated_data):
api_key = validated_data.pop("api_key")
user = self.context["request"].user
instance = UserAPIKey(user=user, **validated_data)
provider = validated_data.get("provider")
try:
instance, _ = UserAPIKey.objects.get_or_create(
user=user,
provider=provider,
defaults={"encrypted_api_key": ""},
)
instance.set_api_key(api_key)
except EncryptionConfigurationError as exc:
raise serializers.ValidationError({"api_key": str(exc)}) from exc
instance.save()
except IntegrityError:
# Defensive retry: in highly concurrent requests a competing create can
# still race. Fall back to updating the existing row instead of 500.
instance = UserAPIKey.objects.get(user=user, provider=provider)
try:
instance.set_api_key(api_key)
except EncryptionConfigurationError as exc:
raise serializers.ValidationError({"api_key": str(exc)}) from exc
instance.save(update_fields=["encrypted_api_key", "updated_at"])
return instance
def update(self, instance, validated_data):

View File

@@ -51,3 +51,65 @@ class UserAPIKeyConfigurationTests(APITestCase):
self.assertEqual(response.status_code, 400)
self.assertIn("not configured", response.json().get("error", "").lower())
mock_requests_get.assert_not_called()
class UserAPIKeyCreateBehaviorTests(APITestCase):
@override_settings(
FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
)
def setUp(self):
self.user = User.objects.create_user(
username="api-key-create-user",
email="apikey-create@example.com",
password="password123",
)
self.client.force_authenticate(user=self.user)
@override_settings(
FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
)
def test_duplicate_provider_post_updates_existing_key(self):
first_response = self.client.post(
"/api/integrations/api-keys/",
{"provider": "google_maps", "api_key": "first-secret"},
format="json",
)
self.assertEqual(first_response.status_code, 201)
second_response = self.client.post(
"/api/integrations/api-keys/",
{"provider": "google_maps", "api_key": "second-secret"},
format="json",
)
self.assertEqual(second_response.status_code, 201)
from integrations.models import UserAPIKey
records = UserAPIKey.objects.filter(user=self.user, provider="google_maps")
self.assertEqual(records.count(), 1)
self.assertEqual(records.first().get_api_key(), "second-secret")
@override_settings(
FIELD_ENCRYPTION_KEY="YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE="
)
def test_provider_is_normalized_and_still_upserts(self):
self.client.post(
"/api/integrations/api-keys/",
{"provider": "Google_Maps", "api_key": "first-secret"},
format="json",
)
response = self.client.post(
"/api/integrations/api-keys/",
{"provider": " google_maps ", "api_key": "rotated-secret"},
format="json",
)
self.assertEqual(response.status_code, 201)
from integrations.models import UserAPIKey
records = UserAPIKey.objects.filter(user=self.user, provider="google_maps")
self.assertEqual(records.count(), 1)
self.assertEqual(records.first().get_api_key(), "rotated-secret")

View File

@@ -0,0 +1,38 @@
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient, APITestCase
User = get_user_model()
class MCPTokenEndpointTests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
username="mcp-token-user",
email="mcp-token@example.com",
password="password123",
)
def test_requires_authentication(self):
unauthenticated_client = APIClient()
response = unauthenticated_client.get("/auth/mcp-token/")
self.assertIn(response.status_code, [401, 403])
def test_returns_token_for_authenticated_user(self):
self.client.force_authenticate(user=self.user)
response = self.client.get("/auth/mcp-token/")
self.assertEqual(response.status_code, 200)
self.assertIn("token", response.json())
self.assertTrue(Token.objects.filter(user=self.user).exists())
def test_reuses_existing_token(self):
existing_token = Token.objects.create(user=self.user)
self.client.force_authenticate(user=self.user)
response = self.client.get("/auth/mcp-token/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json().get("token"), existing_token.key)
self.assertEqual(Token.objects.filter(user=self.user).count(), 1)

View File

@@ -10,7 +10,12 @@ from users.views import (
EnabledSocialProvidersView,
DisablePasswordAuthenticationView,
)
from .views import get_csrf_token, get_public_url, serve_protected_media
from .views import (
get_csrf_token,
get_mcp_api_token,
get_public_url,
serve_protected_media,
)
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from mcp_server.views import MCPServerStreamableHttpView
@@ -48,6 +53,7 @@ urlpatterns = [
),
name="mcp_server_streamable_http_endpoint",
),
path("auth/mcp-token/", get_mcp_api_token, name="get_mcp_api_token"),
path("auth/", include("allauth.headless.urls")),
# Serve protected media files
re_path(

View File

@@ -5,21 +5,42 @@ from django.conf import settings
from django.http import HttpResponse, HttpResponseForbidden
from django.views.static import serve
from adventures.utils.file_permissions import checkFilePermission
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
from rest_framework.authtoken.models import Token
from rest_framework.decorators import (
api_view,
authentication_classes,
permission_classes,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
def get_csrf_token(request):
csrf_token = get_token(request)
return JsonResponse({'csrfToken': csrf_token})
return JsonResponse({"csrfToken": csrf_token})
def get_public_url(request):
return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')})
return JsonResponse({"PUBLIC_URL": getenv("PUBLIC_URL")})
@api_view(["GET"])
@authentication_classes([SessionAuthentication, TokenAuthentication])
@permission_classes([IsAuthenticated])
def get_mcp_api_token(request):
token, _ = Token.objects.get_or_create(user=request.user)
return Response({"token": token.key})
protected_paths = ["images/", "attachments/"]
protected_paths = ['images/', 'attachments/']
def serve_protected_media(request, path):
if any([path.startswith(protected_path) for protected_path in protected_paths]):
image_id = path.split('/')[1]
image_id = path.split("/")[1]
user = request.user
media_type = path.split('/')[0] + '/'
media_type = path.split("/")[0] + "/"
if checkFilePermission(image_id, user, media_type):
if settings.DEBUG:
# In debug mode, serve the file directly
@@ -27,8 +48,8 @@ def serve_protected_media(request, path):
else:
# In production, use X-Accel-Redirect to serve the file using Nginx
response = HttpResponse()
response['Content-Type'] = ''
response['X-Accel-Redirect'] = '/protectedMedia/' + path
response["Content-Type"] = ""
response["X-Accel-Redirect"] = "/protectedMedia/" + path
return response
else:
return HttpResponseForbidden()
@@ -37,6 +58,6 @@ def serve_protected_media(request, path):
return serve(request, path, document_root=settings.MEDIA_ROOT)
else:
response = HttpResponse()
response['Content-Type'] = ''
response['X-Accel-Redirect'] = '/protectedMedia/' + path
return response
response["Content-Type"] = ""
response["X-Accel-Redirect"] = "/protectedMedia/" + path
return response

View File

@@ -255,6 +255,10 @@ export default defineConfig({
text: "Guides",
collapsed: true,
items: [
{
text: "Travel Agent (MCP)",
link: "/docs/guides/travel_agent",
},
{
text: "Admin Panel",
link: "/docs/guides/admin_panel",

View File

@@ -9,4 +9,15 @@ In addition to the primary configuration variables listed above, there are sever
| `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend |
| `OSRM_BASE_URL` | No | Base URL of the OSRM routing server used for itinerary connector distance/travel-time metrics. The public OSRM demo server is used by default. Set this to point at your own OSRM instance (e.g. `http://osrm:5000`) for higher rate limits or offline use. When the OSRM server is unreachable, the backend automatically falls back to haversine-based approximations so the itinerary UI always shows metrics. | `https://router.project-osrm.org` | Backend |
| `FIELD_ENCRYPTION_KEY` | No* | Fernet key used to encrypt user API keys at rest (integrations API key storage). Generate a 32-byte urlsafe base64 key (e.g. `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`). If missing/invalid, only API-key storage endpoints fail gracefully and the rest of the app remains available. | _(none)_ | Backend |
| `DJANGO_MCP_ENDPOINT` | No | HTTP path used for Django MCP server streamable endpoint. | `api/mcp` | Backend |
| `DJANGO_MCP_ENDPOINT` | No | HTTP path used for the Voyage Travel Agent MCP endpoint. Clients call this endpoint with `Authorization: Token <token>` using a DRF auth token for the target user account. | `api/mcp` | Backend |
## MCP endpoint authentication details
Voyage's MCP endpoint requires token authentication.
- Header format: `Authorization: Token <token>`
- Default endpoint path: `api/mcp`
- Override path with: `DJANGO_MCP_ENDPOINT`
- Token bootstrap endpoint for authenticated sessions: `GET /auth/mcp-token/`
For MCP usage patterns and tool-level examples, see the [Travel Agent (MCP) guide](../guides/travel_agent.md).

View File

@@ -1,6 +1,25 @@
# Updating
Updating Voyage when using docker can be quite easy. Run the following commands to pull the latest version and restart the containers. Make sure you backup your instance before updating just in case!
Updating Voyage when using docker can be quite easy. Run a collections backup before upgrading, then pull the latest version and restart the containers.
## Pre-upgrade backup (recommended)
Before running migrations or updating containers, export a collections snapshot:
```bash
docker compose exec server python manage.py export_collections_backup
```
You can also provide a custom output path:
```bash
docker compose exec server python manage.py export_collections_backup --output /code/backups/collections_backup_pre_upgrade.json
```
The backup file includes a timestamp, record counts, and snapshot data for:
- `Collection`
- `CollectionItineraryItem`
Note: Make sure you are in the same directory as your `docker-compose.yml` file.

View File

@@ -0,0 +1,155 @@
# Travel Agent (MCP)
Voyage includes a **Travel Agent** interface exposed through an **MCP-compatible HTTP endpoint**. This lets external MCP clients read and manage trip itineraries programmatically for authenticated users.
## Endpoint
- Default path: `api/mcp`
- Configurable with: `DJANGO_MCP_ENDPOINT`
If you run Voyage at `https://voyage.example.com`, the default MCP URL is:
```text
https://voyage.example.com/api/mcp
```
## Authentication
MCP requests must include a DRF token in the `Authorization` header:
```text
Authorization: Token <token>
```
Use a token associated with the Voyage user account that should execute the MCP actions.
### Get a token from an authenticated session
Voyage exposes a token bootstrap endpoint for logged-in users:
- `GET /auth/mcp-token/`
Call it with your authenticated browser session (or any authenticated session cookie flow). It returns:
```json
{ "token": "<token>" }
```
Then use that token in all MCP requests with the same header format:
```text
Authorization: Token <token>
```
## Available MCP tools
The Voyage MCP server currently exposes these tools:
- `list_collections`
- `get_collection_details`
- `list_itinerary_items`
- `create_itinerary_item`
- `reorder_itinerary`
### Tool parameters
#### `list_collections`
- No parameters.
#### `get_collection_details`
- `collection_id` (required, string UUID): collection to inspect.
#### `list_itinerary_items`
- `collection_id` (optional, string UUID): if provided, limits results to one collection.
#### `create_itinerary_item`
Required:
- `collection_id` (string UUID)
- `content_type` (`location` \| `transportation` \| `note` \| `lodging` \| `visit` \| `checklist`)
- `object_id` (string UUID, id of the referenced content object)
Optional:
- `date` (ISO date string, required when `is_global` is `false`)
- `is_global` (boolean, default `false`; when `true`, `date` must be omitted)
- `order` (integer; if omitted, Voyage appends to the end of the relevant scope)
#### `reorder_itinerary`
Required:
- `items` (list of item update objects)
Each entry in `items` should include:
- `id` (required, string UUID of `CollectionItineraryItem`)
- `date` (ISO date string for dated items)
- `order` (integer target order)
- `is_global` (optional boolean; include when moving between global and dated scopes)
## End-to-end example flow
This example shows a typical interaction from an MCP client.
1. **Connect** to the MCP endpoint using your Voyage server URL and token header.
2. Call **`list_collections`** to find the trip/collection you want to work with.
3. Call **`get_collection_details`** for the selected collection ID to inspect current trip context.
4. Call **`list_itinerary_items`** for a specific date or collection scope.
5. Call **`create_itinerary_item`** to add a new stop (for example, a location or note) to the itinerary.
6. Call **`reorder_itinerary`** to persist the final ordering after insertion.
### Example request headers (HTTP transport)
```http
POST /api/mcp HTTP/1.1
Host: voyage.example.com
Authorization: Token <token>
Content-Type: application/json
```
### Example interaction sequence (conceptual)
```text
Client -> list_collections
Server -> [{"id": "6c5d9f61-2f09-4882-b277-8884b633d36b", "name": "Japan 2026"}, ...]
Client -> get_collection_details({"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b"})
Server -> {...collection metadata...}
Client -> list_itinerary_items({"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b"})
Server -> [...current ordered itinerary items...]
Client -> create_itinerary_item({
"collection_id": "6c5d9f61-2f09-4882-b277-8884b633d36b",
"content_type": "location",
"object_id": "fe7ee379-8a2b-456d-9c59-1eafcf83979b",
"date": "2026-06-12",
"order": 3
})
Server -> {"id": "5eb8c40c-7e36-4709-b4ec-7dc4cfa26ca5", ...}
Client -> reorder_itinerary({"items": [
{
"id": "5eb8c40c-7e36-4709-b4ec-7dc4cfa26ca5",
"date": "2026-06-12",
"order": 0
},
{
"id": "a044f903-d788-4f67-bba7-3ee73da6d504",
"date": "2026-06-12",
"order": 1,
"is_global": false
}
]})
Server -> [...updated itinerary items...]
```
## Related docs
- [Advanced Configuration](../configuration/advanced_configuration.md)
- [How to use Voyage](../usage/usage.md)

View File

@@ -23,6 +23,7 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu
- Locations and itineraries can be shared via a public link or directly with other Voyage users.
- Collaborators can view and edit shared itineraries (collections), making planning a breeze.
- **Customizable Themes** 🎨: Choose from 10 built-in themes including Light, Dark, Dim, Night, Forest, Aqua, Catppuccin Mocha, Aesthetic Light, Aesthetic Dark, and Northern Lights. Theme selection persists across sessions.
- **Travel Agent (MCP) access** 🤖: Voyage exposes an authenticated MCP endpoint so external clients can list collections, inspect itineraries, create itinerary items, and reorder trip timelines. See the [Travel Agent (MCP) guide](../guides/travel_agent.md).
## Why Voyage?

View File

@@ -2,6 +2,8 @@
Welcome to Voyage! This guide will help you get started with Voyage and provide you with an overview of the features available to you.
Voyage also includes a Travel Agent MCP interface for authenticated programmatic trip access and itinerary actions. See the [Travel Agent (MCP) guide](../guides/travel_agent.md).
## Key Terms
#### Locations

View File

@@ -6,6 +6,7 @@
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import Calendar from '~icons/mdi/calendar';
import HelpCircle from '~icons/mdi/help-circle';
import AboutModal from './AboutModal.svelte';
import AccountMultiple from '~icons/mdi/account-multiple';
import MapMarker from '~icons/mdi/map-marker';
@@ -109,11 +110,24 @@
}
};
type NavigationItem = {
path: string;
icon: any;
label: string;
external?: boolean;
};
// Navigation items for better organization
const navigationItems = [
const navigationItems: NavigationItem[] = [
{ path: '/locations', icon: MapMarker, label: 'locations.locations' },
{ path: '/collections', icon: FormatListBulletedSquare, label: 'navbar.collections' },
{ path: '/invites', icon: AccountMultiple, label: 'invites.title' },
{
path: 'https://voyage.app/docs/usage/usage.html',
icon: HelpCircle,
label: 'navbar.documentation',
external: true
},
{ path: '/worldtravel', icon: Earth, label: 'navbar.worldtravel' },
{ path: '/map', icon: MapIcon, label: 'navbar.map' },
{ path: '/calendar', icon: Calendar, label: 'navbar.calendar' },
@@ -149,8 +163,10 @@
<li>
<a
href={item.path}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
class="btn btn-ghost justify-start gap-3 w-full text-left rounded-xl"
class:btn-active={$page.url.pathname === item.path}
class:btn-active={!item.external && $page.url.pathname === item.path}
>
<svelte:component this={item.icon} class="w-5 h-5" />
{$t(item.label)}
@@ -218,9 +234,11 @@
<li>
<a
href={item.path}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
class="btn btn-ghost gap-2 rounded-xl transition-all duration-200 hover:bg-base-200"
class:bg-primary-10={$page.url.pathname === item.path}
class:text-primary={$page.url.pathname === item.path}
class:bg-primary-10={!item.external && $page.url.pathname === item.path}
class:text-primary={!item.external && $page.url.pathname === item.path}
>
<svelte:component this={item.icon} class="w-4 h-4" />
<span class="hidden xl:inline">{$t(item.label)}</span>

View File

@@ -1003,40 +1003,75 @@
return `${rounded}°C`;
}
function optimizeDayOrder(dayIndex: number) {
if (!canModify || isSavingOrder) return;
type HardAnchorTiming = {
primaryTimestamp: number;
secondaryTimestamp: number;
};
const day = days[dayIndex];
if (!day) return;
function parseAnchorDateTime(value: string | null | undefined): number | null {
if (!value) return null;
const sortableItems = day.items.filter((item) => {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return false;
return !!getCoordinatesFromItineraryItem(item);
});
const parsed = DateTime.fromISO(value);
if (!parsed.isValid) return null;
const nonSortableItems = day.items.filter((item) => {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return true;
return !getCoordinatesFromItineraryItem(item);
});
const millis = parsed.toMillis();
return Number.isFinite(millis) ? millis : null;
}
if (sortableItems.length < 2) {
addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize'));
return;
function getHardAnchorTiming(item: ResolvedItineraryItem): HardAnchorTiming | null {
const itemType = item.item?.type || '';
if (itemType === 'transportation') {
const transportation = item.resolvedObject as Transportation | null;
const startTimestamp = parseAnchorDateTime(transportation?.date);
if (startTimestamp === null) return null;
const endTimestamp = parseAnchorDateTime(transportation?.end_date);
return {
primaryTimestamp: startTimestamp,
secondaryTimestamp: endTimestamp ?? startTimestamp
};
}
const remaining = [...sortableItems];
if (itemType === 'lodging') {
const lodging = item.resolvedObject as Lodging | null;
const checkInTimestamp = parseAnchorDateTime(lodging?.check_in);
const checkOutTimestamp = parseAnchorDateTime(lodging?.check_out);
if (checkInTimestamp === null && checkOutTimestamp === null) return null;
const primaryTimestamp = checkInTimestamp ?? checkOutTimestamp;
if (primaryTimestamp === null) return null;
return {
primaryTimestamp,
secondaryTimestamp: checkOutTimestamp ?? checkInTimestamp ?? primaryTimestamp
};
}
return null;
}
function optimizeNearestNeighborSegment(
items: ResolvedItineraryItem[]
): ResolvedItineraryItem[] {
if (items.length < 2) return [...items];
const remaining = [...items];
const sorted: ResolvedItineraryItem[] = [];
const firstItem = remaining.shift();
if (!firstItem) return;
if (!firstItem) return items;
sorted.push(firstItem);
while (remaining.length > 0) {
const last = sorted[sorted.length - 1];
const lastCoords = getCoordinatesFromItineraryItem(last);
if (!lastCoords) break;
if (!lastCoords) {
sorted.push(...remaining);
break;
}
let nearestIndex = 0;
let nearestIndex = -1;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < remaining.length; index += 1) {
@@ -1052,10 +1087,111 @@
}
}
if (nearestIndex < 0) {
sorted.push(...remaining);
break;
}
sorted.push(remaining.splice(nearestIndex, 1)[0]);
}
days[dayIndex].items = [...sorted, ...nonSortableItems];
return sorted;
}
function optimizeDayOrder(dayIndex: number) {
if (!canModify || isSavingOrder) return;
const day = days[dayIndex];
if (!day) return;
const nonShadowItems = day.items.filter((item) => !item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
const shadowItems = day.items.filter((item) => item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]);
const anchorEntries = nonShadowItems
.map((item, originalIndex) => {
const timing = getHardAnchorTiming(item);
if (!timing) return null;
return {
item,
originalIndex,
...timing
};
})
.filter(
(entry): entry is {
item: ResolvedItineraryItem;
originalIndex: number;
primaryTimestamp: number;
secondaryTimestamp: number;
} => !!entry
);
const anchorIndexSet = new Set(anchorEntries.map((entry) => entry.originalIndex));
const movableCoordinateItems = nonShadowItems.filter((item, originalIndex) => {
if (anchorIndexSet.has(originalIndex)) return false;
return !!getCoordinatesFromItineraryItem(item);
});
if (movableCoordinateItems.length < 2) {
addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize'));
return;
}
const anchorsByPosition = [...anchorEntries].sort(
(a, b) => a.originalIndex - b.originalIndex
);
const chronologicalAnchors = [...anchorEntries]
.sort((a, b) => {
if (a.primaryTimestamp !== b.primaryTimestamp) {
return a.primaryTimestamp - b.primaryTimestamp;
}
if (a.secondaryTimestamp !== b.secondaryTimestamp) {
return a.secondaryTimestamp - b.secondaryTimestamp;
}
return a.originalIndex - b.originalIndex;
})
.map((entry) => entry.item);
const movableSegments: ResolvedItineraryItem[][] = Array.from(
{ length: anchorsByPosition.length + 1 },
() => []
);
let activeSegmentIndex = 0;
let nextAnchorPositionIndex = 0;
nonShadowItems.forEach((item, originalIndex) => {
const nextAnchor = anchorsByPosition[nextAnchorPositionIndex];
if (nextAnchor && nextAnchor.originalIndex === originalIndex) {
nextAnchorPositionIndex += 1;
activeSegmentIndex += 1;
return;
}
if (anchorIndexSet.has(originalIndex)) return;
if (!getCoordinatesFromItineraryItem(item)) return;
movableSegments[activeSegmentIndex].push(item);
});
const optimizedPath: ResolvedItineraryItem[] = [];
for (let segmentIndex = 0; segmentIndex < movableSegments.length; segmentIndex += 1) {
const optimizedSegment = optimizeNearestNeighborSegment(movableSegments[segmentIndex]);
optimizedPath.push(...optimizedSegment);
if (segmentIndex < chronologicalAnchors.length) {
optimizedPath.push(chronologicalAnchors[segmentIndex]);
}
}
const nonCoordinateItems = nonShadowItems.filter((item, originalIndex) => {
if (anchorIndexSet.has(originalIndex)) return false;
return !getCoordinatesFromItineraryItem(item);
});
days[dayIndex].items = [...optimizedPath, ...nonCoordinateItems, ...shadowItems];
days = [...days];
isSavingOrder = true;

View File

@@ -734,7 +734,27 @@
"activities": "Aktivitäten",
"trails": "Wanderwege",
"use_imperial": "Verwenden Sie imperiale Einheiten",
"use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten"
"use_imperial_desc": "Verwenden Sie imperiale Einheiten (Füße, Zoll, Pfund) anstelle von metrischen Einheiten",
"ai_api_keys": "KI-API-Schlüssel",
"ai_api_keys_desc": "Verwalten Sie nur schreibbare API-Schlüssel für Reiseagenten-Empfehlungen.",
"travel_agent_help_title": "So verwenden Sie den Reiseagenten",
"travel_agent_help_body": "Öffnen Sie eine Sammlung und wechseln Sie zu Empfehlungen, um mit dem Reiseagenten nach Vorschlägen zu suchen.",
"travel_agent_help_open_collections": "Sammlungen öffnen",
"travel_agent_help_setup_guide": "Einrichtungsanleitung für Reiseagenten",
"saved_api_keys": "Gespeicherte API-Schlüssel",
"no_api_keys_saved": "Noch keine API-Schlüssel gespeichert.",
"add_api_key": "API-Schlüssel hinzufügen",
"provider": "Anbieter",
"api_key_value": "API-Schlüssel",
"api_key_value_placeholder": "Geben Sie Ihren API-Schlüssel ein",
"api_key_write_only_hint": "Aus Sicherheitsgründen ist Ihr Klartextschlüssel nur zum Schreiben verfügbar und wird nach dem Speichern nicht mehr angezeigt.",
"save_api_key": "API-Schlüssel speichern",
"api_keys_saved": "API-Schlüssel gespeichert.",
"api_keys_deleted": "API-Schlüssel gelöscht.",
"api_keys_generic_error": "API-Schlüssel können derzeit nicht aktualisiert werden.",
"api_keys_value_required": "Bitte geben Sie einen API-Schlüssel ein.",
"api_keys_config_unavailable": "API-Schlüsselspeicher ist nicht verfügbar",
"api_keys_config_guidance": "Bitten Sie Ihren Serveradministrator, FIELD_ENCRYPTION_KEY zu konfigurieren, und versuchen Sie es erneut."
},
"checklist": {
"checklist_delete_error": "Fehler beim Löschen der Checkliste",

View File

@@ -735,7 +735,27 @@
"use_imperial": "Use Imperial Units",
"use_imperial_desc": "Use imperial units (feet, inches, pounds) instead of metric units",
"trails": "Trails",
"activities": "Activities"
"activities": "Activities",
"ai_api_keys": "AI API Keys",
"ai_api_keys_desc": "Manage write-only API keys for travel-agent recommendations.",
"saved_api_keys": "Saved API Keys",
"no_api_keys_saved": "No API keys saved yet.",
"add_api_key": "Add API Key",
"provider": "Provider",
"api_key_value": "API Key",
"api_key_value_placeholder": "Enter your API key",
"api_key_write_only_hint": "For security, your plaintext key is write-only and is never shown after saving.",
"save_api_key": "Save API Key",
"api_keys_saved": "API key saved.",
"api_keys_deleted": "API key deleted.",
"api_keys_generic_error": "Unable to update API keys right now.",
"api_keys_value_required": "Please enter an API key.",
"api_keys_config_unavailable": "API key storage is unavailable",
"api_keys_config_guidance": "Ask your server administrator to configure FIELD_ENCRYPTION_KEY and try again.",
"travel_agent_help_title": "How to use the travel agent",
"travel_agent_help_body": "Open a collection and switch to Recommendations to interact with the travel agent for place suggestions.",
"travel_agent_help_open_collections": "Open Collections",
"travel_agent_help_setup_guide": "Travel agent setup guide"
},
"collection": {
"collection_created": "Collection created successfully!",

View File

@@ -734,7 +734,10 @@
"use_imperial": "İngiliz Ölçü Birimlerini Kullan",
"use_imperial_desc": "Metrik birimler yerine İngiliz birimlerini (fit, inç, pound) kullanın",
"trails": "Patikalar",
"activities": "Aktiviteler"
"activities": "Aktiviteler",
"ai_api_keys": "Yapay zeka API anahtarları",
"saved_api_keys": "Kaydedilen API anahtarları",
"add_api_key": "API anahtarı ekle"
},
"collection": {
"collection_created": "Koleksiyon başarıyla oluşturuldu!",

View File

@@ -51,9 +51,7 @@
if (browser) {
init({
fallbackLocale: locales.includes(navigator.language.split('-')[0])
? navigator.language.split('-')[0]
: 'en',
fallbackLocale: 'en',
initialLocale: data.locale
});
// get the locale cookie if it exists and set it as the initial locale if it exists

View File

@@ -16,6 +16,14 @@ type MFAAuthenticatorResponse = {
}[];
};
type UserAPIKey = {
id: string;
provider: string;
masked_api_key: string;
created_at: string;
updated_at: string;
};
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
return redirect(302, '/');
@@ -85,6 +93,21 @@ export const load: PageServerLoad = async (event) => {
let wandererEnabled = integrations.wanderer.exists as boolean;
let wandererExpired = integrations.wanderer.expired as boolean;
let apiKeys: UserAPIKey[] = [];
let apiKeysConfigError: string | null = null;
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
headers: {
Cookie: `sessionid=${sessionId}`
}
});
if (apiKeysFetch.ok) {
apiKeys = (await apiKeysFetch.json()) as UserAPIKey[];
} else if (apiKeysFetch.status === 503) {
const errorBody = (await apiKeysFetch.json()) as { detail?: string };
apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.';
}
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = '';
if (!publicUrlFetch.ok) {
@@ -101,10 +124,13 @@ export const load: PageServerLoad = async (event) => {
authenticators,
immichIntegration,
publicUrl,
mcpTokenHeaderFormat: 'Authorization: Token <token>',
socialProviders,
googleMapsEnabled,
stravaGlobalEnabled,
stravaUserEnabled,
apiKeys,
apiKeysConfigError,
wandererEnabled,
wandererExpired
}

View File

@@ -16,7 +16,6 @@
import WandererLogoSrc from '$lib/assets/wanderer.svg';
export let data: PageData;
console.log(data);
let user: User;
let emails: typeof data.props.emails;
if (data.user) {
@@ -37,6 +36,21 @@
let stravaUserEnabled = data.props.stravaUserEnabled;
let wandererEnabled = data.props.wandererEnabled;
let wandererExpired = data.props.wandererExpired;
type UserAPIKey = {
id: string;
provider: string;
masked_api_key: string;
created_at: string;
updated_at: string;
};
let userApiKeys: UserAPIKey[] = data.props.apiKeys ?? [];
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
let newApiKeyProvider = 'google_maps';
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
let mcpToken: string | null = null;
let isLoadingMcpToken = false;
let activeSection: string = 'profile';
// typed alias for social providers to satisfy TypeScript
@@ -82,6 +96,7 @@
{ id: 'security', icon: '🔒', label: () => $t('settings.security') },
{ id: 'emails', icon: '📧', label: () => $t('settings.emails') },
{ id: 'integrations', icon: '🔗', label: () => $t('settings.integrations') },
{ id: 'ai_api_keys', icon: '🤖', label: () => $t('settings.ai_api_keys') },
{ id: 'import_export', icon: '📦', label: () => $t('settings.backup_restore') },
{ id: 'admin', icon: '⚙️', label: () => $t('settings.admin') },
{ id: 'advanced', icon: '🛠️', label: () => $t('settings.advanced') }
@@ -401,6 +416,162 @@
newWandererIntegration.password = '';
}
}
function getApiKeysErrorMessage(errorBody: any): string {
if (errorBody?.detail) {
return errorBody.detail;
}
if (errorBody?.api_key?.[0]) {
return errorBody.api_key[0];
}
if (errorBody?.provider?.[0]) {
return errorBody.provider[0];
}
return $t('settings.api_keys_generic_error');
}
async function addUserApiKey(event: SubmitEvent) {
event.preventDefault();
if (!newApiKeyValue.trim()) {
addToast('error', $t('settings.api_keys_value_required'));
return;
}
isSavingApiKey = true;
try {
const res = await fetch('/api/integrations/api-keys/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
provider: newApiKeyProvider,
api_key: newApiKeyValue
})
});
let payload: any = null;
try {
payload = await res.json();
} catch {
payload = null;
}
if (res.ok && payload) {
const existingIndex = userApiKeys.findIndex((key) => key.provider === payload.provider);
if (existingIndex >= 0) {
const updated = [...userApiKeys];
updated[existingIndex] = payload;
userApiKeys = updated.sort((a, b) => a.provider.localeCompare(b.provider));
} else {
userApiKeys = [...userApiKeys, payload].sort((a, b) => a.provider.localeCompare(b.provider));
}
newApiKeyValue = '';
apiKeysConfigError = null;
addToast('success', $t('settings.api_keys_saved'));
return;
}
if (res.status === 503) {
apiKeysConfigError = getApiKeysErrorMessage(payload);
addToast('error', $t('settings.api_keys_config_unavailable'));
return;
}
addToast('error', getApiKeysErrorMessage(payload));
} catch {
addToast('error', $t('settings.api_keys_generic_error'));
} finally {
isSavingApiKey = false;
}
}
async function deleteUserApiKey(apiKey: UserAPIKey) {
deletingApiKeyId = apiKey.id;
try {
const res = await fetch(`/api/integrations/api-keys/${apiKey.id}/`, {
method: 'DELETE'
});
if (res.ok || res.status === 204) {
userApiKeys = userApiKeys.filter((key) => key.id !== apiKey.id);
addToast('success', $t('settings.api_keys_deleted'));
return;
}
let payload: any = null;
try {
payload = await res.json();
} catch {
payload = null;
}
if (res.status === 503) {
apiKeysConfigError = getApiKeysErrorMessage(payload);
addToast('error', $t('settings.api_keys_config_unavailable'));
return;
}
addToast('error', getApiKeysErrorMessage(payload));
} catch {
addToast('error', $t('settings.api_keys_generic_error'));
} finally {
deletingApiKeyId = null;
}
}
function getMaskedMcpToken(token: string): string {
if (token.length <= 8) {
return '••••••••';
}
return `${token.slice(0, 4)}••••••••${token.slice(-4)}`;
}
async function fetchOrCreateMcpToken() {
isLoadingMcpToken = true;
try {
const res = await fetch('/auth/mcp-token/', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) {
addToast('error', $t('settings.generic_error'));
return;
}
const payload = (await res.json()) as { token?: string };
if (!payload.token) {
addToast('error', $t('settings.generic_error'));
return;
}
mcpToken = payload.token;
addToast('success', 'MCP token ready.');
} catch {
addToast('error', $t('settings.generic_error'));
} finally {
isLoadingMcpToken = false;
}
}
async function copyMcpAuthHeader() {
if (!mcpToken) {
addToast('error', 'Generate token first.');
return;
}
const authHeader = `Authorization: Token ${mcpToken}`;
try {
await navigator.clipboard.writeText(authHeader);
addToast('success', $t('adventures.copied_to_clipboard'));
} catch {
addToast('error', $t('adventures.copy_failed'));
}
}
</script>
{#if isMFAModalOpen}
@@ -1292,6 +1463,189 @@
</div>
{/if}
<!-- AI API Keys Section -->
{#if activeSection === 'ai_api_keys'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">
<div class="flex items-center gap-4 mb-6">
<div class="p-3 bg-primary/10 rounded-xl">
<span class="text-2xl">🤖</span>
</div>
<div>
<h2 class="text-2xl font-bold">{$t('settings.ai_api_keys')}</h2>
<p class="text-base-content/70">
{$t('settings.ai_api_keys_desc')}
</p>
</div>
</div>
{#if apiKeysConfigError}
<div class="alert alert-warning mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<div>
<p class="font-semibold">{$t('settings.api_keys_config_unavailable')}</p>
<p class="text-sm">{apiKeysConfigError}</p>
<p class="text-sm mt-1">{$t('settings.api_keys_config_guidance')}</p>
</div>
</div>
{/if}
<div class="alert alert-info mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="font-semibold">{$t('settings.travel_agent_help_title')}</p>
<p class="text-sm">{$t('settings.travel_agent_help_body')}</p>
<p class="text-sm mt-1 flex flex-wrap gap-3">
<a class="link link-primary" href="/collections"
>{$t('settings.travel_agent_help_open_collections')}</a
>
<a
class="link link-primary"
href="https://voyage.app/docs/usage/usage.html"
target="_blank"
rel="noopener noreferrer"
>{$t('settings.travel_agent_help_setup_guide')}</a
>
</p>
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
<p class="text-sm text-base-content/70 mb-4">
Create or fetch your personal token for MCP clients. The same token is reused if one
already exists.
</p>
<div class="flex flex-wrap gap-3 mb-4">
<button
class="btn btn-primary"
on:click={fetchOrCreateMcpToken}
disabled={isLoadingMcpToken}
>
{#if isLoadingMcpToken}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{mcpToken ? 'Refresh token' : 'Get MCP token'}
</button>
<button
class="btn btn-outline"
on:click={copyMcpAuthHeader}
disabled={!mcpToken}
>
{$t('settings.copy')}
</button>
</div>
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-base-content/60">Token</div>
<div class="font-mono text-sm p-3 rounded-lg bg-base-100 border border-base-300">
{mcpToken ? getMaskedMcpToken(mcpToken) : 'Not generated yet'}
</div>
</div>
<div class="mt-4 p-4 bg-base-100 rounded-lg border border-base-300">
<div class="text-sm font-medium mb-1">Use this exact auth header format</div>
<div class="font-mono text-sm">{data.props.mcpTokenHeaderFormat}</div>
</div>
</div>
<div class="p-6 bg-base-200 rounded-xl mb-6">
<h3 class="text-lg font-semibold mb-4">{$t('settings.saved_api_keys')}</h3>
{#if userApiKeys.length === 0}
<p class="text-base-content/70">{$t('settings.no_api_keys_saved')}</p>
{:else}
<div class="space-y-3">
{#each userApiKeys as apiKey}
<div class="flex items-center justify-between gap-4 p-4 bg-base-100 rounded-lg">
<div>
<div class="font-medium">{apiKey.provider}</div>
<div class="text-sm text-base-content/70 font-mono">
{apiKey.masked_api_key}
</div>
</div>
<button
class="btn btn-sm btn-error"
on:click={() => deleteUserApiKey(apiKey)}
disabled={deletingApiKeyId === apiKey.id}
>
{#if deletingApiKeyId === apiKey.id}
<span class="loading loading-spinner loading-xs"></span>
{/if}
{$t('adventures.remove')}
</button>
</div>
{/each}
</div>
{/if}
</div>
<div class="p-6 bg-base-200 rounded-xl">
<h3 class="text-lg font-semibold mb-4">{$t('settings.add_api_key')}</h3>
<form class="space-y-4" on:submit={addUserApiKey}>
<div class="form-control">
<label class="label" for="api-key-provider">
<span class="label-text font-medium">{$t('settings.provider')}</span>
</label>
<select
id="api-key-provider"
class="select select-bordered select-primary w-full"
bind:value={newApiKeyProvider}
>
<option value="google_maps">Google Maps</option>
</select>
</div>
<div class="form-control">
<label class="label" for="api-key-value">
<span class="label-text font-medium">{$t('settings.api_key_value')}</span>
</label>
<input
id="api-key-value"
type="password"
class="input input-bordered input-primary focus:input-primary"
bind:value={newApiKeyValue}
placeholder={$t('settings.api_key_value_placeholder')}
required
autocomplete="off"
/>
<p class="text-sm text-base-content/70 mt-1">
{$t('settings.api_key_write_only_hint')}
</p>
</div>
<button class="btn btn-primary" type="submit" disabled={isSavingApiKey}>
{#if isSavingApiKey}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{$t('settings.save_api_key')}
</button>
</form>
</div>
</div>
{/if}
<!-- import export -->
{#if activeSection === 'import_export'}
<div class="bg-base-100 rounded-2xl shadow-xl p-8">