mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -38,6 +38,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
|
||||
- **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips.
|
||||
- **Atlas** — global travel journal: bucket list, visited countries and regions.
|
||||
- **Vacay** — vacation-day planner that tracks leave across team members and years.
|
||||
- **Journey** — cross-trip travel narrative with dated entries, contributors, and share links. Requires the Journey addon.
|
||||
|
||||
## Key workflows
|
||||
|
||||
@@ -75,6 +76,7 @@ The following features are optional and may not be available on every TREK insta
|
||||
- **Collab** — shared notes, polls, and chat messages for group trips.
|
||||
- **Atlas** — bucket list and visited-country/region tracking.
|
||||
- **Vacay** — team vacation-day planner with public holiday integration.
|
||||
- **Journey** — cross-trip travel narrative with entries, contributors, and share links.
|
||||
|
||||
## Behavioral rules
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getNotifications } from '../services/inAppNotifications';
|
||||
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
|
||||
import { canRead, canReadTrips } from './scopes';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
@@ -381,4 +382,57 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Journey resources (Journey addon)
|
||||
if (isAddonEnabled(ADDON_IDS.JOURNEY) && canRead(scopes, 'journey')) {
|
||||
server.registerResource(
|
||||
'journeys',
|
||||
'trek://journeys',
|
||||
{ description: 'All journeys owned or contributed to by the current user', mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const journeys = listJourneys(userId);
|
||||
return jsonContent(uri.href, journeys);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-detail',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}', { list: undefined }),
|
||||
{ description: 'Single journey with entries, contributors, and trip links', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const journey = getJourneyFull(id, userId);
|
||||
if (!journey) return accessDenied(uri.href);
|
||||
return jsonContent(uri.href, journey);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-entries',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}/entries', { list: undefined }),
|
||||
{ description: 'All entries in a journey (date, text, mood, linked trip)', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const j = canAccessJourney(id, userId);
|
||||
if (!j) return accessDenied(uri.href);
|
||||
const entries = listEntries(id, userId);
|
||||
return jsonContent(uri.href, entries);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-contributors',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}/contributors', { list: undefined }),
|
||||
{ description: 'Contributors (owners and collaborators) of a journey', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const j = getJourneyFull(id, userId);
|
||||
if (!j) return accessDenied(uri.href);
|
||||
return jsonContent(uri.href, (j as any).contributors ?? []);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ export const SCOPES = {
|
||||
VACAY_WRITE: 'vacay:write',
|
||||
GEO_READ: 'geo:read',
|
||||
WEATHER_READ: 'weather:read',
|
||||
JOURNEY_READ: 'journey:read',
|
||||
JOURNEY_WRITE: 'journey:write',
|
||||
JOURNEY_SHARE: 'journey:share',
|
||||
} as const;
|
||||
|
||||
export type Scope = typeof SCOPES[keyof typeof SCOPES];
|
||||
@@ -64,6 +67,9 @@ export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
|
||||
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
|
||||
'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
|
||||
'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' },
|
||||
'journey:read': { label: 'View journeys', description: 'Read journeys, entries, and contributor list', group: 'Journey' },
|
||||
'journey:write': { label: 'Manage journeys', description: 'Create, update, and delete journeys and their entries', group: 'Journey' },
|
||||
'journey:share': { label: 'Manage journey links', description: 'Create, update, and revoke public share links for journeys', group: 'Journey' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,6 +107,12 @@ export function canShareTrips(scopes: string[] | null): boolean {
|
||||
return scopes.includes('trips:share');
|
||||
}
|
||||
|
||||
/** journey:share is a separate scope for managing public share links for journeys */
|
||||
export function canShareJourneys(scopes: string[] | null): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.includes('journey:share');
|
||||
}
|
||||
|
||||
export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } {
|
||||
const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope));
|
||||
return { valid: invalid.length === 0, invalid };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { registerTodoTools } from './tools/todos';
|
||||
import { registerAssignmentTools } from './tools/assignments';
|
||||
import { registerJourneyTools } from './tools/journey';
|
||||
import { registerReservationTools } from './tools/reservations';
|
||||
import { registerTagTools } from './tools/tags';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
@@ -12,6 +13,7 @@ import { registerBudgetTools } from './tools/budget';
|
||||
import { registerPackingTools } from './tools/packing';
|
||||
import { registerCollabTools } from './tools/collab';
|
||||
import { registerTripTools } from './tools/trips';
|
||||
import { registerTransportTools } from './tools/transports';
|
||||
import { registerVacayTools } from './tools/vacay';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
|
||||
@@ -40,6 +42,10 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerCollabTools(server, userId, scopes);
|
||||
|
||||
registerTransportTools(server, userId, scopes);
|
||||
|
||||
registerJourneyTools(server, userId, scopes);
|
||||
|
||||
registerVacayTools(server, userId, scopes);
|
||||
|
||||
registerTodoTools(server, userId, scopes);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { findByIata, searchAirports } from '../../services/airportService';
|
||||
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
|
||||
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||
import {
|
||||
@@ -110,4 +111,38 @@ export function registerMapsWeatherTools(server: McpServer, userId: number, scop
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- AIRPORTS ---
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'search_airports',
|
||||
{
|
||||
description: 'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(200).describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'),
|
||||
limit: z.number().int().min(1).max(50).optional().default(10),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
const airports = searchAirports(query, limit ?? 10);
|
||||
return ok({ airports });
|
||||
}
|
||||
);
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'get_airport',
|
||||
{
|
||||
description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.',
|
||||
inputSchema: {
|
||||
iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ iata }) => {
|
||||
const airport = findByIata(iata);
|
||||
if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true };
|
||||
return ok({ airport });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { onPlaceDeleted } from '../../services/journeyService';
|
||||
import { listCategories } from '../../services/categoryService';
|
||||
import { searchPlaces } from '../../services/mapsService';
|
||||
import {
|
||||
@@ -159,4 +160,57 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'import_places_from_url',
|
||||
{
|
||||
description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
|
||||
source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, url, source }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const result = source === 'google-list'
|
||||
? await importGoogleList(String(tripId), url)
|
||||
: await importNaverList(String(tripId), url);
|
||||
|
||||
if ('error' in result) {
|
||||
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
}
|
||||
|
||||
for (const place of result.places) {
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
}
|
||||
return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'bulk_delete_places',
|
||||
{
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, placeIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const deleted = deletePlacesMany(String(tripId), placeIds);
|
||||
for (const id of deleted) {
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId: id });
|
||||
try { onPlaceDeleted(id); } catch {}
|
||||
}
|
||||
return ok({ deleted, count: deleted.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "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(),
|
||||
@@ -78,12 +78,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'update_reservation',
|
||||
{
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/other → use assignment_id to link to a day assignment.',
|
||||
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', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "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(),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
} from '../../services/reservationService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
||||
|
||||
const endpointSchema = z.array(z.object({
|
||||
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
|
||||
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
|
||||
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
|
||||
})).optional();
|
||||
|
||||
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!canWrite(scopes, 'reservations')) return;
|
||||
|
||||
server.registerTool(
|
||||
'create_transport',
|
||||
{
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||
title: z.string().min(1).max(200),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'),
|
||||
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const { reservation } = createReservation(tripId, {
|
||||
title,
|
||||
type,
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
location: undefined,
|
||||
confirmation_number,
|
||||
notes,
|
||||
day_id: start_day_id,
|
||||
end_day_id: end_day_id ?? start_day_id,
|
||||
status: status ?? 'pending',
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review,
|
||||
});
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_transport',
|
||||
{
|
||||
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']).optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional(),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'),
|
||||
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
|
||||
const resolvedType = type ?? existing.type;
|
||||
if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType))
|
||||
return { content: [{ type: 'text' as const, text: 'Reservation is not a transport type. Use update_reservation instead.' }], isError: true };
|
||||
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title,
|
||||
type,
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
confirmation_number,
|
||||
notes,
|
||||
day_id: start_day_id,
|
||||
end_day_id,
|
||||
status,
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review,
|
||||
}, existing);
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_transport',
|
||||
{
|
||||
description: 'Delete a transport booking from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const { deleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -64,17 +64,17 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_reservation', () => {
|
||||
it('creates a basic flight reservation', async () => {
|
||||
it('creates a basic reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_reservation',
|
||||
arguments: { tripId: trip.id, title: 'Flight to Rome', type: 'flight' },
|
||||
arguments: { tripId: trip.id, title: 'Eiffel Tower Tour', type: 'tour' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reservation.title).toBe('Flight to Rome');
|
||||
expect(data.reservation.type).toBe('flight');
|
||||
expect(data.reservation.title).toBe('Eiffel Tower Tour');
|
||||
expect(data.reservation.type).toBe('tour');
|
||||
expect(data.reservation.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user