Spaces:
Sleeping
Sleeping
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; | |
| import { z } from 'zod'; | |
| import { canAccessTrip } from '../../db/database'; | |
| import { isDemoUser } from '../../services/authService'; | |
| import { | |
| listItems as listTodoItems, createItem as createTodoItem, updateItem as updateTodoItem, | |
| deleteItem as deleteTodoItem, reorderItems as reorderTodoItems, | |
| getCategoryAssignees as getTodoCategoryAssignees, updateCategoryAssignees as updateTodoCategoryAssignees, | |
| } from '../../services/todoService'; | |
| import { | |
| safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, | |
| TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, | |
| demoDenied, noAccess, ok, hasTripPermission, permissionDenied, | |
| } from './_shared'; | |
| import { canRead, canWrite } from '../scopes'; | |
| import { isAddonEnabled } from '../../services/adminService'; | |
| import { ADDON_IDS } from '../../addons'; | |
| export function registerTodoTools(server: McpServer, userId: number, scopes: string[] | null): void { | |
| const R = canRead(scopes, 'todos'); | |
| const W = canWrite(scopes, 'todos'); | |
| if (!isAddonEnabled(ADDON_IDS.PACKING)) return; | |
| // --- TODOS --- | |
| if (R) server.registerTool( | |
| 'list_todos', | |
| { | |
| description: 'List all to-do items for a trip, ordered by position.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_READONLY, | |
| }, | |
| async ({ tripId }) => { | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| const items = listTodoItems(tripId); | |
| return ok({ items }); | |
| } | |
| ); | |
| if (W) server.registerTool( | |
| 'create_todo', | |
| { | |
| description: 'Create a new to-do item for a trip.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| name: z.string().min(1).max(500).describe('To-do item name'), | |
| category: z.string().max(100).optional().describe('Category (e.g. "Logistics", "Booking")'), | |
| due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Due date (YYYY-MM-DD)'), | |
| description: z.string().max(2000).optional().describe('Additional description'), | |
| assigned_user_id: z.number().int().positive().optional().describe('User ID to assign this task to'), | |
| priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=low, 2=medium, 3=high'), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, | |
| }, | |
| async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => { | |
| if (isDemoUser(userId)) return demoDenied(); | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied(); | |
| const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); | |
| safeBroadcast(tripId, 'todo:created', { item }); | |
| return ok({ item }); | |
| } | |
| ); | |
| if (W) server.registerTool( | |
| 'update_todo', | |
| { | |
| description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| itemId: z.number().int().positive(), | |
| name: z.string().min(1).max(500).optional(), | |
| category: z.string().max(100).optional(), | |
| due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional().describe('Set to null to clear the due date'), | |
| description: z.string().max(2000).nullable().optional().describe('Set to null to clear'), | |
| assigned_user_id: z.number().int().positive().nullable().optional().describe('Set to null to unassign'), | |
| priority: z.number().int().min(0).max(3).nullable().optional(), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_WRITE, | |
| }, | |
| async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => { | |
| if (isDemoUser(userId)) return demoDenied(); | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied(); | |
| // Build bodyKeys to signal which nullable fields were explicitly provided | |
| const bodyKeys: string[] = []; | |
| if (due_date !== undefined) bodyKeys.push('due_date'); | |
| if (description !== undefined) bodyKeys.push('description'); | |
| if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id'); | |
| if (priority !== undefined) bodyKeys.push('priority'); | |
| const item = updateTodoItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys); | |
| if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true }; | |
| safeBroadcast(tripId, 'todo:updated', { item }); | |
| return ok({ item }); | |
| } | |
| ); | |
| if (W) server.registerTool( | |
| 'toggle_todo', | |
| { | |
| description: 'Mark a to-do item as checked (done) or unchecked.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| itemId: z.number().int().positive(), | |
| checked: z.boolean().describe('True to mark done, false to uncheck'), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_WRITE, | |
| }, | |
| async ({ tripId, itemId, checked }) => { | |
| if (isDemoUser(userId)) return demoDenied(); | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied(); | |
| const item = updateTodoItem(tripId, itemId, { checked: checked ? 1 : 0 }, []); | |
| if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true }; | |
| safeBroadcast(tripId, 'todo:updated', { item }); | |
| return ok({ item }); | |
| } | |
| ); | |
| if (W) server.registerTool( | |
| 'delete_todo', | |
| { | |
| description: 'Delete a to-do item.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| itemId: z.number().int().positive(), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_DELETE, | |
| }, | |
| async ({ tripId, itemId }) => { | |
| if (isDemoUser(userId)) return demoDenied(); | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied(); | |
| const deleted = deleteTodoItem(tripId, itemId); | |
| if (!deleted) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true }; | |
| safeBroadcast(tripId, 'todo:deleted', { itemId }); | |
| return ok({ success: true }); | |
| } | |
| ); | |
| if (W) server.registerTool( | |
| 'reorder_todos', | |
| { | |
| description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| orderedIds: z.array(z.number().int().positive()).min(1).describe('All item IDs in the desired order'), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_WRITE, | |
| }, | |
| async ({ tripId, orderedIds }) => { | |
| if (isDemoUser(userId)) return demoDenied(); | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied(); | |
| reorderTodoItems(tripId, orderedIds); | |
| return ok({ success: true }); | |
| } | |
| ); | |
| if (R) server.registerTool( | |
| 'get_todo_category_assignees', | |
| { | |
| description: 'Get the default assignees configured per to-do category for a trip.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_READONLY, | |
| }, | |
| async ({ tripId }) => { | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| const assignees = getTodoCategoryAssignees(tripId); | |
| return ok({ assignees }); | |
| } | |
| ); | |
| if (W) server.registerTool( | |
| 'set_todo_category_assignees', | |
| { | |
| description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.', | |
| inputSchema: { | |
| tripId: z.number().int().positive(), | |
| categoryName: z.string().min(1).max(100).describe('Category name'), | |
| userIds: z.array(z.number().int().positive()).describe('User IDs to assign as defaults for this category'), | |
| }, | |
| annotations: TOOL_ANNOTATIONS_WRITE, | |
| }, | |
| async ({ tripId, categoryName, userIds }) => { | |
| if (isDemoUser(userId)) return demoDenied(); | |
| if (!canAccessTrip(tripId, userId)) return noAccess(); | |
| if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied(); | |
| const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds); | |
| safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees }); | |
| return ok({ assignees }); | |
| } | |
| ); | |
| } | |