feat(mcp): align MCP surface with current app state

- Add Journey addon tools (list, get, entries, contributors, suggestions,
  available trips, create/update/delete journey and entries, reorder,
  contributors CRUD, preferences, share link management)
- Add Journey resources (trek://journeys and sub-resources)
- Split transport (flight/train/car/cruise) into dedicated tools with
  endpoints[] and needs_review support; narrow reservation types to
  non-transport only
- Add airport lookup tools (search_airports, get_airport) under geo:read
- Add import_places_from_url and bulk_delete_places to places tools
- Add journey:read/write/share OAuth scopes (27 total) with translations
  across all 15 locales
- Default end_day to start_day when creating a transport (MCP + UI)
- Fix MCP.md drift: addon gates, removed files resource, corrected
  get_trip_summary description, todos under Packing addon
This commit is contained in:
jubnl
2026-04-19 16:03:32 +02:00
parent 151950d08a
commit da39b570eb
29 changed files with 937 additions and 32 deletions
+421
View File
@@ -0,0 +1,421 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import {
addContributor, addTripToJourney, canAccessJourney, createEntry, createJourney,
deleteEntry, deleteJourney, getJourneyFull, getSuggestions, listEntries,
listJourneys, listUserTrips, removeContributor, removeTripFromJourney,
reorderEntries, updateContributorRole, updateEntry, updateJourney,
updateJourneyPreferences,
} from '../../services/journeyService';
import {
createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink,
} from '../../services/journeyShareService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
demoDenied, ok,
} from './_shared';
import { canRead, canShareJourneys, canWrite } from '../scopes';
function notFound(msg: string) {
return { content: [{ type: 'text' as const, text: msg }], isError: true };
}
export function registerJourneyTools(server: McpServer, userId: number, scopes: string[] | null): void {
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return;
const R = canRead(scopes, 'journey');
const W = canWrite(scopes, 'journey');
const S = canShareJourneys(scopes);
// --- READ TOOLS ---
if (R) server.registerTool(
'list_journeys',
{
description: 'List all journeys owned or contributed to by the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const journeys = listJourneys(userId);
return ok({ journeys });
}
);
if (R) server.registerTool(
'get_journey',
{
description: 'Get a full journey including entries, contributors, and linked trips.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
);
if (R) server.registerTool(
'list_journey_entries',
{
description: 'List all entries in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const entries = listEntries(journeyId, userId);
return ok({ entries });
}
);
if (R) server.registerTool(
'list_journey_contributors',
{
description: 'List all contributors (owner and collaborators) of a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const journey = getJourneyFull(journeyId, userId);
if (!journey) return notFound('Journey not found or access denied.');
return ok({ contributors: (journey as any).contributors ?? [] });
}
);
if (R) server.registerTool(
'get_journey_suggestions',
{
description: 'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = getSuggestions(userId);
return ok({ trips });
}
);
if (R) server.registerTool(
'list_journey_available_trips',
{
description: 'List all trips available to link to a journey.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const trips = listUserTrips(userId);
return ok({ trips });
}
);
// --- WRITE TOOLS ---
if (W) server.registerTool(
'create_journey',
{
description: 'Create a new journey, optionally linking existing trips.',
inputSchema: {
title: z.string().min(1).max(200),
subtitle: z.string().max(300).optional(),
trip_ids: z.array(z.number().int().positive()).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ title, subtitle, trip_ids }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = createJourney(userId, { title, subtitle, trip_ids });
return ok({ journey });
}
);
if (W) server.registerTool(
'update_journey',
{
description: 'Update an existing journey\'s title, subtitle, cover, or status. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
subtitle: z.string().max(300).optional(),
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, title, subtitle, status }) => {
if (isDemoUser(userId)) return demoDenied();
const journey = updateJourney(journeyId, userId, { title, subtitle, status });
if (!journey) return notFound('Journey not found or access denied.');
return ok({ journey });
}
);
if (W) server.registerTool(
'delete_journey',
{
description: 'Delete a journey. Owner only — this cannot be undone.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourney(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'add_journey_trip',
{
description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
const success = addTripToJourney(journeyId, tripId, userId);
return ok({ success });
}
);
if (W) server.registerTool(
'remove_journey_trip',
{
description: 'Unlink a trip from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId, tripId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeTripFromJourney(journeyId, tripId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success });
}
);
if (W) server.registerTool(
'create_journey_entry',
{
description: 'Create a new entry in a journey.',
inputSchema: {
journeyId: z.number().int().positive(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Entry date (YYYY-MM-DD)'),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'),
location_name: z.string().optional(),
mood: z.string().optional(),
sort_order: z.number().int().min(0).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, entry_date, title, story, entry_time, location_name, mood, sort_order }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
if (!entry) return notFound('Journey not found or access denied.');
return ok({ entry });
}
);
if (W) server.registerTool(
'update_journey_entry',
{
description: 'Update an existing journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
title: z.string().max(300).optional(),
story: z.string().optional(),
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
entry_time: z.string().optional(),
mood: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ entryId, title, story, entry_date, entry_time, mood }) => {
if (isDemoUser(userId)) return demoDenied();
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
if (!entry) return notFound('Entry not found or access denied.');
return ok({ entry });
}
);
if (W) server.registerTool(
'delete_journey_entry',
{
description: 'Delete a journey entry.',
inputSchema: {
entryId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ entryId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteEntry(entryId, userId, undefined);
if (!success) return notFound('Entry not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'reorder_journey_entries',
{
description: 'Reorder entries within a journey by providing the desired order of entry IDs.',
inputSchema: {
journeyId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
const success = reorderEntries(journeyId, userId, orderedIds, undefined);
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'add_journey_contributor',
{
description: 'Add a contributor to a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = addContributor(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'update_journey_contributor_role',
{
description: 'Update the role of a journey contributor. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
role: z.enum(['editor', 'viewer']),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, targetUserId, role }) => {
if (isDemoUser(userId)) return demoDenied();
const success = updateContributorRole(journeyId, userId, targetUserId, role);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'remove_journey_contributor',
{
description: 'Remove a contributor from a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId, targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = removeContributor(journeyId, userId, targetUserId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
if (W) server.registerTool(
'update_journey_preferences',
{
description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).',
inputSchema: {
journeyId: z.number().int().positive(),
hide_skeletons: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId, hide_skeletons }) => {
if (isDemoUser(userId)) return demoDenied();
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
if (!result) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
// --- SHARE TOOLS ---
if (S) server.registerTool(
'get_journey_share_link',
{
description: 'Get the current public share link for a journey. Returns null if none exists.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ journeyId }) => {
const shareLink = getJourneyShareLink(journeyId);
return ok({ shareLink });
}
);
if (S) server.registerTool(
'create_journey_share_link',
{
description: 'Create or update the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const shareLink = createOrUpdateJourneyShareLink(journeyId, userId, {});
if (!shareLink) return notFound('Journey not found or access denied.');
return ok({ shareLink });
}
);
if (S) server.registerTool(
'delete_journey_share_link',
{
description: 'Revoke the public share link for a journey. Owner only.',
inputSchema: {
journeyId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ journeyId }) => {
if (isDemoUser(userId)) return demoDenied();
const success = deleteJourneyShareLink(journeyId, userId);
if (!success) return notFound('Journey not found or access denied.');
return ok({ success: true });
}
);
}