mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
093e069ccc
* refactor(auth): session token validation and password-change consistency * refactor(journey): entry field allow-list and public share-link consistency * refactor(mcp): align tool authorization with the REST permission checks * chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
|
import { z } from 'zod';
|
|
import { canAccessTrip } from '../../db/database';
|
|
import { isDemoUser } from '../../services/authService';
|
|
import {
|
|
createItem as createPackingItem, updateItem as updatePackingItem,
|
|
deleteItem as deletePackingItem,
|
|
reorderItems as reorderPackingItems,
|
|
listBags, createBag, updateBag, deleteBag, setBagMembers,
|
|
getCategoryAssignees as getPackingCategoryAssignees,
|
|
updateCategoryAssignees as updatePackingCategoryAssignees,
|
|
applyTemplate, saveAsTemplate, bulkImport,
|
|
} from '../../services/packingService';
|
|
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 registerPackingTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
|
const R = canRead(scopes, 'packing');
|
|
const W = canWrite(scopes, 'packing');
|
|
|
|
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
|
|
|
|
// --- PACKING ---
|
|
|
|
if (W) server.registerTool(
|
|
'create_packing_item',
|
|
{
|
|
description: 'Add an item to the packing checklist for a trip.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
name: z.string().min(1).max(200),
|
|
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, name, category }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
const item = createPackingItem(tripId, { name, category: category || 'General' });
|
|
safeBroadcast(tripId, 'packing:created', { item });
|
|
return ok({ item });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'toggle_packing_item',
|
|
{
|
|
description: 'Check or uncheck a packing item.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
itemId: z.number().int().positive(),
|
|
checked: z.boolean(),
|
|
},
|
|
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 = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
|
|
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
|
safeBroadcast(tripId, 'packing:updated', { item });
|
|
return ok({ item });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'delete_packing_item',
|
|
{
|
|
description: 'Remove an item from the packing checklist.',
|
|
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 = deletePackingItem(tripId, itemId);
|
|
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
|
safeBroadcast(tripId, 'packing:deleted', { itemId });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
// --- PACKING (update) ---
|
|
|
|
if (W) server.registerTool(
|
|
'update_packing_item',
|
|
{
|
|
description: 'Rename a packing item or change its category.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
itemId: z.number().int().positive(),
|
|
name: z.string().min(1).max(200).optional(),
|
|
category: z.string().max(100).optional(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, itemId, name, category }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
|
|
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
|
|
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
|
|
safeBroadcast(tripId, 'packing:updated', { item });
|
|
return ok({ item });
|
|
}
|
|
);
|
|
|
|
// --- PACKING ADVANCED ---
|
|
|
|
if (W) server.registerTool(
|
|
'reorder_packing_items',
|
|
{
|
|
description: 'Set the display order of packing items within a trip.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
orderedIds: z.array(z.number().int().positive()).describe('Packing item IDs in 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();
|
|
reorderPackingItems(tripId, orderedIds);
|
|
safeBroadcast(tripId, 'packing:reordered', { orderedIds });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
if (R) server.registerTool(
|
|
'list_packing_bags',
|
|
{
|
|
description: 'List all packing bags for a trip.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const bags = listBags(tripId);
|
|
return ok({ bags });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'create_packing_bag',
|
|
{
|
|
description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
name: z.string().min(1).max(100),
|
|
color: z.string().optional(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, name, color }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
const bag = createBag(tripId, { name, color });
|
|
safeBroadcast(tripId, 'packing:bag-created', { bag });
|
|
return ok({ bag });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'update_packing_bag',
|
|
{
|
|
description: 'Rename or recolor a packing bag.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
bagId: z.number().int().positive(),
|
|
name: z.string().optional(),
|
|
color: z.string().optional(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, bagId, name, color }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
const fields: Record<string, unknown> = {};
|
|
const bodyKeys: string[] = [];
|
|
if (name !== undefined) { fields.name = name; bodyKeys.push('name'); }
|
|
if (color !== undefined) { fields.color = color; bodyKeys.push('color'); }
|
|
const bag = updateBag(tripId, bagId, fields, bodyKeys);
|
|
safeBroadcast(tripId, 'packing:bag-updated', { bag });
|
|
return ok({ bag });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'delete_packing_bag',
|
|
{
|
|
description: 'Delete a packing bag (items in the bag are unassigned, not deleted).',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
bagId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_DELETE,
|
|
},
|
|
async ({ tripId, bagId }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
deleteBag(tripId, bagId);
|
|
safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'set_bag_members',
|
|
{
|
|
description: 'Assign trip members to a packing bag (determines who packs what bag).',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
bagId: z.number().int().positive(),
|
|
userIds: z.array(z.number().int().positive()),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, bagId, userIds }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
setBagMembers(tripId, bagId, userIds);
|
|
safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
if (R) server.registerTool(
|
|
'get_packing_category_assignees',
|
|
{
|
|
description: 'Get which trip members are assigned to each packing category.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
},
|
|
async ({ tripId }) => {
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const assignees = getPackingCategoryAssignees(tripId);
|
|
return ok({ assignees });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'set_packing_category_assignees',
|
|
{
|
|
description: 'Assign trip members to a packing category.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
categoryName: z.string().min(1).max(100),
|
|
userIds: z.array(z.number().int().positive()),
|
|
},
|
|
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();
|
|
updatePackingCategoryAssignees(tripId, categoryName, userIds);
|
|
safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'apply_packing_template',
|
|
{
|
|
description: 'Apply a packing template to a trip (adds items from the template).',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
templateId: z.number().int().positive(),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, templateId }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
const applied = applyTemplate(tripId, templateId);
|
|
if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true };
|
|
safeBroadcast(tripId, 'packing:template-applied', { templateId });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'save_packing_template',
|
|
{
|
|
description: 'Save the current packing list as a reusable template.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
templateName: z.string().min(1).max(100),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, templateName }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
saveAsTemplate(tripId, userId, templateName);
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
if (W) server.registerTool(
|
|
'bulk_import_packing',
|
|
{
|
|
description: 'Import multiple packing items at once from a list.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
items: z.array(z.object({
|
|
name: z.string().min(1).max(200),
|
|
category: z.string().optional(),
|
|
quantity: z.number().int().positive().optional(),
|
|
})).min(1),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, items }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
|
|
bulkImport(tripId, items);
|
|
safeBroadcast(tripId, 'packing:updated', {});
|
|
return ok({ success: true, count: items.length });
|
|
}
|
|
);
|
|
}
|