Spaces:
Sleeping
Sleeping
| 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 }) => { | |
| if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.'); | |
| 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 }); | |
| } | |
| ); | |
| } | |