From 6cff4802bf3efea6b45bbc60097a45ef2a2f4d5d Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 16:18:35 +0200 Subject: [PATCH] refactor(mcp): tool ids derive from the shared idSchema instead of an inline zod chain per field --- .../nest/accommodations/accommodations.mcp.ts | 32 +++---- .../src/nest/assignments/assignments.mcp.ts | 62 +++++++------- server/src/nest/atlas/atlas.mcp.ts | 5 +- server/src/nest/budget/budget.mcp.ts | 71 ++++++++-------- server/src/nest/categories/categories.mcp.ts | 7 +- server/src/nest/collab/collab.mcp.ts | 43 +++++----- .../src/nest/collections/collections.mcp.ts | 43 +++++----- server/src/nest/day-notes/day-notes.mcp.ts | 18 ++-- server/src/nest/days/days.mcp.ts | 17 ++-- server/src/nest/doc-sync/doc-sync.mcp.ts | 7 +- server/src/nest/feeds/feeds.mcp.ts | 10 +-- server/src/nest/files/files.mcp.ts | 32 +++---- server/src/nest/integrations/dawarich.mcp.ts | 21 ++--- server/src/nest/journey/journey.mcp.ts | 59 ++++++------- .../nest/notifications/notifications.mcp.ts | 7 +- server/src/nest/packing/packing.mcp.ts | 61 ++++++------- server/src/nest/places/places.mcp.ts | 49 +++++------ .../contributions/plugin-search.mcp.ts | 3 +- .../contributions/trip-warnings.mcp.ts | 4 +- .../reservation-import.mcp.ts | 5 +- .../src/nest/reservations/reservations.mcp.ts | 66 +++++++------- server/src/nest/roadtrip/charging.mcp.ts | 7 +- .../src/nest/roadtrip/day-boundaries.mcp.ts | 6 +- server/src/nest/roadtrip/google-route.mcp.ts | 4 +- .../src/nest/roadtrip/roadtrip-hazards.mcp.ts | 4 +- .../nest/roadtrip/roadtrip-planning.mcp.ts | 4 +- .../nest/roadtrip/roadtrip-preferences.mcp.ts | 7 +- server/src/nest/roadtrip/roadtrip.mcp.ts | 32 +++---- .../school-holidays/school-holidays.mcp.ts | 2 +- server/src/nest/share/share.mcp.ts | 7 +- server/src/nest/tags/tags.mcp.ts | 5 +- server/src/nest/todo/todo.mcp.ts | 31 +++---- server/src/nest/transit/transit.mcp.ts | 5 +- .../src/nest/trip-invite/trip-invite.mcp.ts | 9 +- server/src/nest/trips/trips.mcp.ts | 32 +++---- server/src/nest/vacay/vacay.mcp.ts | 19 +++-- .../tests/unit/mcp/tool-input-schemas.test.ts | 85 +++++++++++++++++++ 37 files changed, 489 insertions(+), 392 deletions(-) create mode 100644 server/tests/unit/mcp/tool-input-schemas.test.ts diff --git a/server/src/nest/accommodations/accommodations.mcp.ts b/server/src/nest/accommodations/accommodations.mcp.ts index e52075c98..5d8ea79ca 100644 --- a/server/src/nest/accommodations/accommodations.mcp.ts +++ b/server/src/nest/accommodations/accommodations.mcp.ts @@ -4,7 +4,7 @@ import { ok, } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; -import { placeWebsiteSchema } from '@trek/shared'; +import { idSchema, placeWebsiteSchema } from '@trek/shared'; import { z } from 'zod'; import { PlacesService } from '../places/places.service'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; @@ -47,10 +47,10 @@ export class AccommodationsMcp { name: 'create_accommodation', description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range. This also puts the place on its check-in day, so the stay shows up as a stop on the route.', inputSchema: { - tripId: z.number().int().positive(), - place_id: z.number().int().positive().describe('The place to use as the accommodation'), - start_day_id: z.number().int().positive().describe('Check-in day ID'), - end_day_id: z.number().int().positive().describe('Check-out day ID'), + tripId: idSchema, + place_id: idSchema.describe('The place to use as the accommodation'), + start_day_id: idSchema.describe('Check-in day ID'), + end_day_id: idSchema.describe('Check-out day ID'), check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'), check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), @@ -76,21 +76,21 @@ export class AccommodationsMcp { name: 'create_place_accommodation', description: 'Create a new place, set it as an accommodation for a date range and put it on its check-in day, in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, name: z.string().min(1).max(200), description: z.string().max(2000).optional(), lat: z.number().optional(), lng: z.number().optional(), address: z.string().max(500).optional(), - category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), + category_id: idSchema.optional().describe('Category ID — use list_categories to see available options'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), place_notes: z.string().max(2000).optional().describe('Notes for the place'), website: z.string().max(500).optional(), phone: z.string().max(50).optional(), - start_day_id: z.number().int().positive().describe('Check-in day ID'), - end_day_id: z.number().int().positive().describe('Check-out day ID'), + start_day_id: idSchema.describe('Check-in day ID'), + end_day_id: idSchema.describe('Check-out day ID'), check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'), check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), @@ -143,11 +143,11 @@ export class AccommodationsMcp { name: 'update_accommodation', description: 'Update fields on an existing accommodation. Moving it to another check-in day or place moves the stop it put on the route with it.', inputSchema: { - tripId: z.number().int().positive(), - accommodationId: z.number().int().positive(), - place_id: z.number().int().positive().optional(), - start_day_id: z.number().int().positive().optional(), - end_day_id: z.number().int().positive().optional(), + tripId: idSchema, + accommodationId: idSchema, + place_id: idSchema.optional(), + start_day_id: idSchema.optional(), + end_day_id: idSchema.optional(), check_in: z.string().max(10).optional(), check_in_end: z.string().max(10).optional().describe('Check-in window end time e.g. "20:00"'), check_out: z.string().max(10).optional(), @@ -175,8 +175,8 @@ export class AccommodationsMcp { name: 'delete_accommodation', description: 'Delete an accommodation from a trip. The stop this booking put on its check-in day goes with it; a stop that was already there stays.', inputSchema: { - tripId: z.number().int().positive(), - accommodationId: z.number().int().positive(), + tripId: idSchema, + accommodationId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'trips', mode: 'write' }, diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index 38b8f5c4d..14aabf304 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -6,7 +6,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; -import { assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared'; +import { idSchema, assignmentEndDayRequestSchema, type AssignmentEndDayRequest } from '@trek/shared'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { AssignmentsService } from './assignments.service'; import { DaysService } from '../days/days.service'; @@ -32,9 +32,9 @@ export class AssignmentsMcp { name: 'assign_place_to_day', description: 'Assign a place to a specific day in a trip.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - placeId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, + placeId: idSchema, notes: z.string().max(500).optional(), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -58,8 +58,8 @@ export class AssignmentsMcp { name: 'set_assignment_end_day', description: 'End the travel day after this visit and its stay. Applies only with daily travel times enabled. Pass false to follow the default again.', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), + tripId: idSchema, + assignmentId: idSchema, ...assignmentEndDayRequestSchema.shape, }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -81,9 +81,9 @@ export class AssignmentsMcp { name: 'unassign_place', description: 'Remove a place assignment from a day.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - assignmentId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, + assignmentId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'places', mode: 'write' }, @@ -106,8 +106,8 @@ export class AssignmentsMcp { name: 'clear_day_assignments', description: 'Remove every place from one day in a single step. The day itself, its notes and its bookings stay; the places stay in the trip and can be planned again. Returns the removed assignment ids.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'places', mode: 'write' }, @@ -129,8 +129,8 @@ export class AssignmentsMcp { name: 'update_assignment_time', description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), + tripId: idSchema, + assignmentId: idSchema, place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'), end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'), }, @@ -164,8 +164,8 @@ export class AssignmentsMcp { name: 'update_assignment_notes', description: 'Set or clear the day-specific note on a place assignment (the note assign_place_to_day and create_and_assign_place accept at creation). Pass null or an empty string to clear it.', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), + tripId: idSchema, + assignmentId: idSchema, // Mirrors the REST contract (assignmentNotesRequestSchema): uncapped on // purpose, unlike the create tools' max(500) — a longer note written // through REST or the plugin RPC must stay editable here (#2163). @@ -190,8 +190,8 @@ export class AssignmentsMcp { name: 'set_leg_transport_mode', description: 'Set the travel mode of a route leg for a place assignment. Use direction "outgoing" (default) for the common case: the leg leaving this stop toward the next. Use direction "incoming" ONLY when this stop\'s arriving leg originates from something that is not itself a place assignment (e.g. a flight/train booking arrival, or a morning hotel departure) – setting "incoming" on an ordinary place-to-place leg is stored but has no effect on route rendering, because it targets a column that is only read for non-place origins. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:/". Any other value is stored but drawn as a driving route. null clears it so the leg inherits the day default.', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), + tripId: idSchema, + assignmentId: idSchema, transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to inherit the day default'), direction: z.enum(['outgoing', 'incoming']).default('outgoing').describe('Which leg to set: "outgoing" (leaving this stop, default) or "incoming" (arriving at it)'), }, @@ -218,8 +218,8 @@ export class AssignmentsMcp { name: 'set_assignment_route_excluded', description: 'Keep a place on its day but leave it out of that day route, or put it back. An excluded stop still shows in the day plan and on the map, and the route runs from the stop before it straight to the one after. Use it for a place only visited on foot from somewhere nearby, or one that is just a point of interest.', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), + tripId: idSchema, + assignmentId: idSchema, excluded: z.boolean().describe('true leaves the stop out of the route, false routes it again'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -241,10 +241,10 @@ export class AssignmentsMcp { name: 'move_assignment', description: 'Move a place assignment to a different day.', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), - newDayId: z.number().int().positive(), - oldDayId: z.number().int().positive().optional().describe('Deprecated and ignored — the server derives the source day from the assignment'), + tripId: idSchema, + assignmentId: idSchema, + newDayId: idSchema, + oldDayId: idSchema.optional().describe('Deprecated and ignored — the server derives the source day from the assignment'), orderIndex: z.number().int().min(0).optional().default(0), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -273,8 +273,8 @@ export class AssignmentsMcp { name: 'get_assignment_participants', description: 'Get the list of users participating in a specific place assignment.', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), + tripId: idSchema, + assignmentId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'places', mode: 'read' }, @@ -293,9 +293,9 @@ export class AssignmentsMcp { name: 'set_assignment_participants', description: 'Set the participants for a place assignment (replaces current list).', inputSchema: { - tripId: z.number().int().positive(), - assignmentId: z.number().int().positive(), - userIds: z.array(z.number().int().positive()).describe('User IDs to set as participants; empty array clears all'), + tripId: idSchema, + assignmentId: idSchema, + userIds: z.array(idSchema).describe('User IDs to set as participants; empty array clears all'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'places', mode: 'write' }, @@ -316,9 +316,9 @@ export class AssignmentsMcp { name: 'reorder_day_assignments', description: 'Reorder places within a day by providing the assignment IDs in the desired order.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'), + tripId: idSchema, + dayId: idSchema, + assignmentIds: z.array(idSchema).min(1).max(200).describe('Assignment IDs in desired display order'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'places', mode: 'write' }, diff --git a/server/src/nest/atlas/atlas.mcp.ts b/server/src/nest/atlas/atlas.mcp.ts index ad8377d81..1337ddab2 100644 --- a/server/src/nest/atlas/atlas.mcp.ts +++ b/server/src/nest/atlas/atlas.mcp.ts @@ -5,6 +5,7 @@ import { errorResult, ok, } from '../../nest-mcp'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { AtlasService, BucketItemExistsError } from './atlas.service'; import { addonGate } from '../addons/addon-gate'; @@ -102,7 +103,7 @@ export class AtlasMcp { name: 'delete_bucket_list_item', description: 'Remove an item from your travel bucket list.', inputSchema: { - itemId: z.number().int().positive(), + itemId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: atlasAddonOn, @@ -275,7 +276,7 @@ export class AtlasMcp { name: 'update_bucket_list_item', description: 'Update a bucket list item (notes, name, target date, location).', inputSchema: { - itemId: z.number().int().positive(), + itemId: idSchema, name: z.string().optional(), notes: z.string().optional(), lat: z.number().nullable().optional(), diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 5eb66bc2c..f353d01b4 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -6,6 +6,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { ADDON_IDS } from '../../addons'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; @@ -32,13 +33,13 @@ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET); * of a refund recorded as a negative expense. */ const payersSchema = z.array(z.strictObject({ - user_id: z.number().int().positive(), + user_id: idSchema, amount: z.number(), })).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.'); /** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */ const splitMembersSchema = z.array(z.strictObject({ - user_id: z.number().int().positive(), + user_id: idSchema, amount: z.number(), })).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.'); @@ -186,16 +187,16 @@ export class BudgetMcp { name: 'create_budget_item', description: 'Add a budget/expense item to a trip. The cost is split equally among member_ids (omit to split across all trip members, or pass [] for a planning-only entry with no split); for an uneven split, give `members` the amount each participant owes instead. Use `payers` to record who actually paid and how much. Ask the user which trip members share this expense and who paid (resolve user IDs with list_trip_members) rather than guessing. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, name: z.string().min(1).max(200), category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'), total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'), currency: z.string().max(10).nullable().optional().describe('ISO currency code (e.g. "EUR"); defaults to the trip currency'), - member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense equally. Omit to split across all trip members (owner + members); pass [] for no split.'), + member_ids: z.array(idSchema).optional().describe('Trip member user IDs splitting this expense equally. Omit to split across all trip members (owner + members); pass [] for no split.'), members: splitMembersSchema.optional().describe('Uneven split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Use this instead of member_ids, never alongside it.'), payers: payersSchema.optional().describe('Who paid how much, in the expense currency. When given, total_price is derived from the sum. Ask the user; do not guess.'), expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD'), - place_id: z.number().int().positive().optional().describe('Place on this trip the expense belongs to (the museum ticket for that museum), linking it in the planner'), + place_id: idSchema.optional().describe('Place on this trip the expense belongs to (the museum ticket for that museum), linking it in the planner'), note: z.string().max(500).optional(), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -234,8 +235,8 @@ export class BudgetMcp { name: 'delete_budget_item', description: 'Delete a budget item from a trip.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: budgetAddonOn, @@ -256,21 +257,21 @@ export class BudgetMcp { name: 'update_budget_item', description: 'Update an existing budget/expense item in a trip. You can also re-split it (equally via member_ids, unevenly via members), change the currency it was entered in, move it to another date, and record who actually paid via payers (amounts in the expense currency). When changing who shares an expense or who paid, ask the user rather than guessing; resolve user IDs with list_trip_members. A foreign currency is frozen at the exchange rate the server quotes today; when the server has none, the expense counts in no balance or total and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, name: z.string().min(1).max(200).optional(), category: z.string().max(100).optional(), total_price: z.number().optional(), currency: z.string().max(10).nullable().optional().describe('ISO currency code the expense is in (e.g. "USD"); null puts it back in the trip currency. Changing it re-freezes the FX rate at today\'s rate.'), - member_ids: z.array(z.number().int().positive()).optional().describe('Trip member user IDs splitting this expense equally; replaces the current split. Omit to leave unchanged, pass [] for no split.'), + member_ids: z.array(idSchema).optional().describe('Trip member user IDs splitting this expense equally; replaces the current split. Omit to leave unchanged, pass [] for no split.'), members: splitMembersSchema.optional().describe('Uneven split: what each participant owes, in the expense currency; replaces the current split. The amounts must add up to the expense total. Use this instead of member_ids, never alongside it.'), payers: payersSchema.optional().describe('Replaces who paid how much, in the expense currency. Omit to leave unchanged. Ask the user; do not guess.'), - persons: z.number().int().positive().nullable().optional(), - days: z.number().int().positive().nullable().optional(), + persons: idSchema.nullable().optional(), + days: idSchema.nullable().optional(), expense_date: z.string().max(40).nullable().optional().describe('Date the expense occurred, YYYY-MM-DD; null clears it. Omit to leave unchanged.'), note: z.string().max(500).nullable().optional(), - reservation_id: z.number().int().positive().nullable().optional().describe('Booking or transport on this trip to link the expense to (a booking can carry several); null unlinks it and keeps the expense. Omit to leave unchanged.'), - place_id: z.number().int().positive().nullable().optional().describe('Place on this trip to link the expense to; null unlinks it and keeps the expense. Omit to leave unchanged.'), + reservation_id: idSchema.nullable().optional().describe('Booking or transport on this trip to link the expense to (a booking can carry several); null unlinks it and keeps the expense. Omit to leave unchanged.'), + place_id: idSchema.nullable().optional().describe('Place on this trip to link the expense to; null unlinks it and keeps the expense. Omit to leave unchanged.'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: budgetAddonOn, @@ -314,13 +315,13 @@ export class BudgetMcp { name: 'create_budget_item_with_members', description: 'Create a budget/expense item and set the trip members splitting it equally in one atomic operation. If userIds is omitted, the cost is split across all trip members; pass an explicit list to split among a subset, or an empty array for a planning-only entry with no split. Ask the user which members share this expense rather than guessing; resolve user IDs with list_trip_members. Only use when the item does not yet exist; if it already exists, use set_budget_item_members directly. For an uneven split, a foreign currency or a date, use create_budget_item instead.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, name: z.string().min(1).max(200), category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'), total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'), note: z.string().max(500).optional(), - userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'), - place_id: z.number().int().positive().optional().describe('Place on this trip the expense belongs to (the museum ticket for that museum), linking it in the planner'), + userIds: z.array(idSchema).optional().describe('User IDs splitting this item; omit to split across all trip members, or pass an empty array for no split'), + place_id: idSchema.optional().describe('Place on this trip the expense belongs to (the museum ticket for that museum), linking it in the planner'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: budgetAddonOn, @@ -354,9 +355,9 @@ export class BudgetMcp { name: 'set_budget_item_members', description: 'Set which trip members are splitting a budget item (replaces current member list). Ask the user which members share the expense; resolve user IDs with list_trip_members.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), - userIds: z.array(z.number().int().positive()).describe('User IDs splitting this item; empty array clears all'), + tripId: idSchema, + itemId: idSchema, + userIds: z.array(idSchema).describe('User IDs splitting this item; empty array clears all'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: budgetAddonOn, @@ -376,9 +377,9 @@ export class BudgetMcp { name: 'toggle_budget_member_paid', description: 'Mark or unmark a member as having paid their share of a budget item.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), - memberId: z.number().int().positive().describe('User ID of the member'), + tripId: idSchema, + itemId: idSchema, + memberId: idSchema.describe('User ID of the member'), paid: z.boolean(), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -399,7 +400,7 @@ export class BudgetMcp { name: 'get_settlement_summary', description: "See each member's net balance, the suggested payments to settle shared expenses, and what the trip finally costs each member once every reimbursement is accounted for (`finalBudgets`, each figure with the rows it is made of under `sources`). Amounts are in `summary.currency`: the `base` asked for when the server can quote it, otherwise the trip's base currency. An expense or payment in a foreign currency with no exchange rate is left out of every figure and listed under `summary.unconverted` (item_ids, settlement_ids, currencies); freeze_budget_rates pins today's rate on those. Call this before recording a settlement so you know who should pay whom and how much.", inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, base: z.string().max(10).optional().describe('ISO currency code to compute balances in; defaults to the trip currency'), }, annotations: TOOL_ANNOTATIONS_READONLY, @@ -419,7 +420,7 @@ export class BudgetMcp { name: 'list_settlements', description: 'List the recorded settle-up payments for a trip (who paid whom, how much, when).', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, when: budgetAddonOn, @@ -434,9 +435,9 @@ export class BudgetMcp { name: 'create_settlement', description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses, optionally with a note. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.", inputSchema: { - tripId: z.number().int().positive(), - from_user_id: z.number().int().positive().describe('User ID of the member who paid'), - to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'), + tripId: idSchema, + from_user_id: idSchema.describe('User ID of the member who paid'), + to_user_id: idSchema.describe('User ID of the member who received the payment'), amount: z.number().positive().describe('Amount paid, in `currency`'), currency: z.string().max(10).nullable().optional().describe("ISO currency code the payment was made in (e.g. \"USD\"); defaults to the trip currency. Its FX rate is frozen now, so the transfer keeps cancelling its expense when live rates drift."), settled_at: z.string().max(40).nullable().optional().describe('Day the payment actually happened, YYYY-MM-DD. Omitted means no day is stored and the ledger files the payment under the day it was recorded.'), @@ -464,10 +465,10 @@ export class BudgetMcp { name: 'update_settlement', description: 'Update a recorded settle-up payment (who paid, who received, the amount, the currency it was made in and its note). Every field is a full replace, so restate the ones that stay the same. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.', inputSchema: { - tripId: z.number().int().positive(), - settlementId: z.number().int().positive(), - from_user_id: z.number().int().positive().describe('User ID of the member who paid'), - to_user_id: z.number().int().positive().describe('User ID of the member who received the payment'), + tripId: idSchema, + settlementId: idSchema, + from_user_id: idSchema.describe('User ID of the member who paid'), + to_user_id: idSchema.describe('User ID of the member who received the payment'), amount: z.number().positive().describe('Amount paid, in `currency`'), currency: z.string().max(10).nullable().optional().describe('ISO currency code the payment was made in (e.g. "USD"); null puts it back in the trip currency. Omit to leave it as recorded, which also keeps the rate frozen at settle time.'), settled_at: z.string().max(40).nullable().optional().describe('Date the payment actually happened, YYYY-MM-DD. Omit to leave it as recorded.'), @@ -495,8 +496,8 @@ export class BudgetMcp { name: 'delete_settlement', description: 'Delete a recorded settle-up payment. This is the undo for create_settlement and restores the affected balances.', inputSchema: { - tripId: z.number().int().positive(), - settlementId: z.number().int().positive(), + tripId: idSchema, + settlementId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: budgetAddonOn, @@ -517,7 +518,7 @@ export class BudgetMcp { name: 'freeze_budget_rates', description: "Pin today's server exchange rate on every expense and settle-up payment of a trip that is in a foreign currency and has no rate frozen yet. That takes in the rows get_settlement_summary lists under `unconverted`, which count in no balance or total until then, and also foreign rows it still converts at today's live rate. Rows that already carry a frozen rate, rows in the trip currency and rows without a currency are never touched, and no rate is taken from the caller. Returns the rows it froze (`items`, `settlements`) and the currencies the server could not quote (`unresolved`), whose rows stay unfrozen.", inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, when: budgetAddonOn, diff --git a/server/src/nest/categories/categories.mcp.ts b/server/src/nest/categories/categories.mcp.ts index d77f225a0..8488dbc0a 100644 --- a/server/src/nest/categories/categories.mcp.ts +++ b/server/src/nest/categories/categories.mcp.ts @@ -4,8 +4,7 @@ import { TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, errorResult, ok, } from '../../nest-mcp'; -import { z } from 'zod'; -import { createCategoryRequestSchema, updateCategoryRequestSchema } from '@trek/shared'; +import { idSchema, createCategoryRequestSchema, updateCategoryRequestSchema } from '@trek/shared'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { adminRequired } from '../../mcp/tools/_shared'; @@ -70,7 +69,7 @@ export class CategoriesMcp { name: 'update_category', description: 'Rename an existing place category or change its colour or icon. Admin only. Every place already carrying the category follows the change, so use this to fix a palette entry rather than to reclassify places.', inputSchema: { - categoryId: z.number().int().positive().describe('Category ID from list_categories'), + categoryId: idSchema.describe('Category ID from list_categories'), name: updateCategoryRequestSchema.shape.name, color: updateCategoryRequestSchema.shape.color.describe('Hex colour for the map marker'), icon: updateCategoryRequestSchema.shape.icon.describe('Emoji shown on the marker'), @@ -89,7 +88,7 @@ export class CategoriesMcp { name: 'delete_category', description: 'Remove a place category from the instance-wide palette. Admin only. Places keep their data but lose the category, across every trip on the instance. Use update_category when the entry only needs fixing.', inputSchema: { - categoryId: z.number().int().positive().describe('Category ID from list_categories'), + categoryId: idSchema.describe('Category ID from list_categories'), }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'places', mode: 'write' }, diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts index 89f48b048..7ec37ccd5 100644 --- a/server/src/nest/collab/collab.mcp.ts +++ b/server/src/nest/collab/collab.mcp.ts @@ -6,6 +6,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { CollabService } from './collab.service'; @@ -74,7 +75,7 @@ export class CollabMcp { name: 'create_collab_note', description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, title: z.string().min(1).max(200), content: z.string().max(10000).optional(), category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'), @@ -103,8 +104,8 @@ export class CollabMcp { name: 'update_collab_note', description: 'Edit an existing collaborative note on a trip.', inputSchema: { - tripId: z.number().int().positive(), - noteId: z.number().int().positive(), + tripId: idSchema, + noteId: idSchema, title: z.string().min(1).max(200).optional(), content: z.string().max(10000).optional(), category: z.string().max(100).optional(), @@ -134,8 +135,8 @@ export class CollabMcp { name: 'delete_collab_note', description: 'Delete a collaborative note from a trip.', inputSchema: { - tripId: z.number().int().positive(), - noteId: z.number().int().positive(), + tripId: idSchema, + noteId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: collabNotesOn, @@ -156,7 +157,7 @@ export class CollabMcp { name: 'list_collab_polls', description: 'List all polls for a trip.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, when: collabPollsOn, @@ -172,7 +173,7 @@ export class CollabMcp { name: 'create_collab_poll', description: 'Create a new poll in the collab panel.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, question: z.string().min(1), options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'), multiple: z.boolean().optional().describe('Allow multiple choice'), @@ -199,8 +200,8 @@ export class CollabMcp { name: 'vote_collab_poll', description: 'Vote on a poll option (or remove vote if already voted for that option).', inputSchema: { - tripId: z.number().int().positive(), - pollId: z.number().int().positive(), + tripId: idSchema, + pollId: idSchema, optionIndex: z.number().int().min(0).describe('Zero-based index of the option to vote for'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -220,8 +221,8 @@ export class CollabMcp { name: 'close_collab_poll', description: 'Close a poll so no more votes can be cast.', inputSchema: { - tripId: z.number().int().positive(), - pollId: z.number().int().positive(), + tripId: idSchema, + pollId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, when: collabPollsOn, @@ -240,8 +241,8 @@ export class CollabMcp { name: 'delete_collab_poll', description: 'Delete a poll and all its votes.', inputSchema: { - tripId: z.number().int().positive(), - pollId: z.number().int().positive(), + tripId: idSchema, + pollId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: collabPollsOn, @@ -260,8 +261,8 @@ export class CollabMcp { name: 'list_collab_messages', description: 'List chat messages for a trip (most recent 100, oldest-first).', inputSchema: { - tripId: z.number().int().positive(), - before: z.number().int().positive().optional().describe('Load messages with ID less than this (pagination)'), + tripId: idSchema, + before: idSchema.optional().describe('Load messages with ID less than this (pagination)'), }, annotations: TOOL_ANNOTATIONS_READONLY, when: collabChatOn, @@ -277,9 +278,9 @@ export class CollabMcp { name: 'send_collab_message', description: "Send a chat message to a trip's collab channel.", inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, text: z.string().min(1), - replyTo: z.number().int().positive().optional().describe('Reply to a specific message ID'), + replyTo: idSchema.optional().describe('Reply to a specific message ID'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: collabChatOn, @@ -298,8 +299,8 @@ export class CollabMcp { name: 'delete_collab_message', description: 'Delete a chat message (only the message owner can delete their own messages).', inputSchema: { - tripId: z.number().int().positive(), - messageId: z.number().int().positive(), + tripId: idSchema, + messageId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: collabChatOn, @@ -318,8 +319,8 @@ export class CollabMcp { name: 'react_collab_message', description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).', inputSchema: { - tripId: z.number().int().positive(), - messageId: z.number().int().positive(), + tripId: idSchema, + messageId: idSchema, emoji: z.string().describe('Single emoji character'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index 0b1035b18..ba90291a7 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -18,6 +18,7 @@ import { collectionInviteRequestSchema, collectionSetStatusFromTripRequestSchema, collectionLinkSchema, COLLECTION_STATUSES, COLLECTION_ROLES, + idSchema, } from '@trek/shared'; import type { CollectionCreateRequest, CollectionUpdateRequest, CollectionSavePlaceRequest, @@ -83,7 +84,7 @@ export class CollectionsMcp { @Tool({ name: 'get_collection', description: 'Get one collection with its members, labels, and all saved places. Each place includes rating_avg / rating_count and the per-member ratings (#1435) so you can plan around highly-rated spots.', - inputSchema: { collectionId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: collectionsAddonOn, access: { group: 'collections', mode: 'read' }, @@ -95,7 +96,7 @@ export class CollectionsMcp { @Tool({ name: 'available_collection_users', description: 'List users who can still be invited to a collection (excludes current members and guests). Use the returned ids with invite_to_collection.', - inputSchema: { collectionId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: collectionsAddonOn, access: { group: 'collections', mode: 'read' }, @@ -150,7 +151,7 @@ export class CollectionsMcp { @Tool({ name: 'update_collection', description: 'Update a collection\'s name, description, colour, icon, cover, links, or sort order. Owner/admin only.', - inputSchema: { collectionId: z.number().int().positive(), ...collectionUpdateRequestSchema.shape, links: linksInput }, + inputSchema: { collectionId: idSchema, ...collectionUpdateRequestSchema.shape, links: linksInput }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -162,7 +163,7 @@ export class CollectionsMcp { @Tool({ name: 'delete_collection', description: 'Permanently delete a collection and all its saved places. Owner only. This cannot be undone.', - inputSchema: { collectionId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -174,7 +175,7 @@ export class CollectionsMcp { @Tool({ name: 'reorder_collections', description: 'Reorder the user\'s collections. Pass every collection id in the desired order.', - inputSchema: { orderedIds: z.array(z.number().int().positive()).min(1) }, + inputSchema: { orderedIds: z.array(idSchema).min(1) }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -201,9 +202,9 @@ export class CollectionsMcp { name: 'save_trip_places_to_collection', description: 'Copy one or more existing trip places into a collection (the server reads each place). Their star ratings (#1435) travel along for members shared on both. Duplicates are skipped unless force is true.', inputSchema: { - collectionId: z.number().int().positive(), - tripId: z.number().int().positive(), - placeIds: z.array(z.number().int().positive()).min(1).max(1000), + collectionId: idSchema, + tripId: idSchema, + placeIds: z.array(idSchema).min(1).max(1000), force: z.boolean().optional(), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -220,7 +221,7 @@ export class CollectionsMcp { @Tool({ name: 'update_collection_place', description: 'Update a saved place\'s name, address, coordinates (lat/lng), description, notes, status, category, links, tags, labels, image, or move it to another collection (set collection_id).', - inputSchema: { placeId: z.number().int().positive(), ...collectionPlaceUpdateRequestSchema.shape, links: linksInput }, + inputSchema: { placeId: idSchema, ...collectionPlaceUpdateRequestSchema.shape, links: linksInput }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -232,7 +233,7 @@ export class CollectionsMcp { @Tool({ name: 'set_collection_place_status', description: 'Set a saved place\'s status: idea, want, or visited.', - inputSchema: { placeId: z.number().int().positive(), status: z.enum(COLLECTION_STATUSES) }, + inputSchema: { placeId: idSchema, status: z.enum(COLLECTION_STATUSES) }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -260,7 +261,7 @@ export class CollectionsMcp { name: 'rate_collection_place', description: "Set or clear the current user's 1-5 star rating on a saved collection place (#1435). Every member rates independently; the place shows the average. Pass null (or omit rating) to remove the user's vote. Ratings a member casts here follow the place into any trip it is later copied to. Use the ratings to capture the user's preferences.", inputSchema: { - placeId: z.number().int().positive(), + placeId: idSchema, rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -274,7 +275,7 @@ export class CollectionsMcp { @Tool({ name: 'delete_collection_place', description: 'Remove a saved place from its collection. Requires delete permission on the list.', - inputSchema: { placeId: z.number().int().positive() }, + inputSchema: { placeId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -312,7 +313,7 @@ export class CollectionsMcp { @Tool({ name: 'update_collection_label', description: 'Rename or recolour a collection label, or change its sort order.', - inputSchema: { labelId: z.number().int().positive(), ...collectionLabelUpdateRequestSchema.shape }, + inputSchema: { labelId: idSchema, ...collectionLabelUpdateRequestSchema.shape }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -324,7 +325,7 @@ export class CollectionsMcp { @Tool({ name: 'delete_collection_label', description: 'Delete a collection label; its assignments on places are cleared.', - inputSchema: { labelId: z.number().int().positive() }, + inputSchema: { labelId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -372,8 +373,8 @@ export class CollectionsMcp { name: 'set_collection_member_role', description: 'Change an accepted member\'s permission role (viewer, editor, or admin). Owner only.', inputSchema: { - collectionId: z.number().int().positive(), - userId: z.number().int().positive(), + collectionId: idSchema, + userId: idSchema, role: z.enum(COLLECTION_ROLES), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -390,7 +391,7 @@ export class CollectionsMcp { @Tool({ name: 'remove_collection_member', description: 'Remove an accepted member from a shared collection (a kick). Owner only.', - inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema, userId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -405,7 +406,7 @@ export class CollectionsMcp { @Tool({ name: 'cancel_collection_invite', description: 'Cancel a pending invite you sent to a user for a collection. Owner only.', - inputSchema: { collectionId: z.number().int().positive(), userId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema, userId: idSchema }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -420,7 +421,7 @@ export class CollectionsMcp { @Tool({ name: 'accept_collection_invite', description: 'Accept a pending invite to join a shared collection.', - inputSchema: { collectionId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -436,7 +437,7 @@ export class CollectionsMcp { @Tool({ name: 'decline_collection_invite', description: 'Decline a pending invite to a shared collection.', - inputSchema: { collectionId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, @@ -448,7 +449,7 @@ export class CollectionsMcp { @Tool({ name: 'leave_collection', description: 'Leave a shared collection you are a member of. The owner cannot leave (delete the list instead).', - inputSchema: { collectionId: z.number().int().positive() }, + inputSchema: { collectionId: idSchema }, annotations: TOOL_ANNOTATIONS_WRITE, when: collectionsAddonOn, access: { group: 'collections', mode: 'write' }, diff --git a/server/src/nest/day-notes/day-notes.mcp.ts b/server/src/nest/day-notes/day-notes.mcp.ts index 2d70ddd2e..b76201c5e 100644 --- a/server/src/nest/day-notes/day-notes.mcp.ts +++ b/server/src/nest/day-notes/day-notes.mcp.ts @@ -5,7 +5,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; -import { NOTE_COLORS, type NoteColor } from '@trek/shared'; +import { idSchema, NOTE_COLORS, type NoteColor } from '@trek/shared'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { DayNotesService } from './day-notes.service'; @@ -48,8 +48,8 @@ export class DayNotesMcp { name: 'create_day_note', description: 'Add a note to a specific day in a trip.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, text: z.string().min(1).max(500), time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'), icon: z.string().max(64).optional().describe('Emoji icon for the note'), @@ -77,9 +77,9 @@ export class DayNotesMcp { name: 'update_day_note', description: 'Edit an existing note on a specific day.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - noteId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, + noteId: idSchema, text: z.string().min(1).max(500).optional(), time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'), icon: z.string().max(64).optional().describe('Emoji icon for the note'), @@ -108,9 +108,9 @@ export class DayNotesMcp { name: 'delete_day_note', description: 'Delete a note from a specific day.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - noteId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, + noteId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'trips', mode: 'write' }, diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index d466abc9b..0e911044b 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { DAY_CREATE_DATED_CONFLICT, dayCreateRequestSchema, dayReorderRequestSchema, dayUpdateRequestSchema, + idSchema, } from '@trek/shared'; import type { DayCreateRequest, DayReorderRequest, DayUpdateRequest } from '@trek/shared'; import { DaysService, DayReorderError, DayAppendError, type DatedDayAppend, type DaySender } from './days.service'; @@ -48,8 +49,8 @@ export class DaysMcp { name: 'update_day', description: 'Set the title and/or the notes of a day in a trip (e.g. "Arrival in Paris", "Free day"). An omitted field keeps its current value, so the title and the notes can be changed independently.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, // The shared contract leaves title unbounded, as the raw-body route it // replaced did; the tool keeps the cap it was ported with so a model // cannot park a paragraph in a day header. @@ -79,7 +80,7 @@ export class DaysMcp { name: 'create_day', description: 'Add a day to a trip. Without `position` the day is appended at the end, optionally with a date and notes. With `position` an empty day is slotted in at that place instead, which is the way to add a day in the middle of an itinerary that already has days. With `dated` the trip grows by the calendar day after its last date: the new day gets that date and goes right behind the last dated day, and the end date of the trip moves to it, while `position` re-dates the days after the slot it fills.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, date: dayCreateRequestSchema.shape.date.describe('ISO date string YYYY-MM-DD, optional for dateless trips'), notes: dayCreateRequestSchema.shape.notes, position: dayCreateRequestSchema.shape.position.describe('1-based slot to insert an empty day at; omit to append at the end. On a dated trip the days keep their calendar slots, so the trip gains one day at its end and bookings move with the day they sit on. date and notes are ignored when this is set, as on the REST route.'), @@ -142,7 +143,7 @@ export class DaysMcp { name: 'reorder_days', description: 'Reorder the days of a trip by listing every one of its day IDs in the desired order. This moves whole days of the itinerary; to move places around inside a single day use reorder_day_assignments instead. Each day keeps its places, notes, stays and bookings, and on a dated trip the calendar dates stay pinned to their slots, so the content moves across the dates.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, orderedIds: dayReorderRequestSchema.shape.orderedIds.min(1).describe('Every day ID of the trip, in the desired order'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -171,8 +172,8 @@ export class DaysMcp { name: 'delete_day', description: 'Delete a day from a trip. The places planned on it stay in the place list of the trip; its notes, title and description are deleted; bookings on it stay on the trip without a day. A stay that checks in or out on the day is cancelled together with its booking and the expense of that booking, while a stay that only runs across the day is kept. The later days move up one place. On a dated trip the dates stay on their positions, so every later day and the bookings on it move one date earlier; a day without a date then takes the last date, and when there is none the trip ends one day earlier. The last day of a trip cannot be deleted.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'trips', mode: 'write' }, @@ -200,8 +201,8 @@ export class DaysMcp { name: 'set_day_default_transport_mode', description: 'Set the whole-day default travel mode for a day. transport_mode is a route profile key: "driving", "walking", "cycling", or a plugin profile written as "plugin:/". Any other value is stored but drawn as a driving route. null clears the default. Per-segment leg modes still override this.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to clear the day default'), }, annotations: TOOL_ANNOTATIONS_WRITE, diff --git a/server/src/nest/doc-sync/doc-sync.mcp.ts b/server/src/nest/doc-sync/doc-sync.mcp.ts index 28debb4e1..62b847b29 100644 --- a/server/src/nest/doc-sync/doc-sync.mcp.ts +++ b/server/src/nest/doc-sync/doc-sync.mcp.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { McpController, Tool, @@ -45,7 +46,7 @@ export class DocSyncMcp { description: 'Show whether this trip\'s documents are synced with an external document store (Paperless-ngx, Papra, Nextcloud, OpenCloud or a Synology NAS), which folder or tag they are bound to, when the last run happened, and how many documents are waiting, in conflict or missing at the provider. Use this before telling someone where their documents live.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'files', mode: 'read' }, @@ -61,7 +62,7 @@ export class DocSyncMcp { description: 'List the documents that need a person to look at them: conflicts where both copies changed, documents the provider refused because of their type or size, and documents that disappeared at the provider. Returns an empty list when everything is in step.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'files', mode: 'read' }, @@ -79,7 +80,7 @@ export class DocSyncMcp { description: 'Run the document sync for this trip now instead of waiting for the next scheduled check. Reports how many documents were pulled in from the provider, pushed out to it, and how many are in conflict. Safe to call repeatedly: a run that is already in progress is not started twice.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, full: z .boolean() .optional() diff --git a/server/src/nest/feeds/feeds.mcp.ts b/server/src/nest/feeds/feeds.mcp.ts index 930e81684..04bafb759 100644 --- a/server/src/nest/feeds/feeds.mcp.ts +++ b/server/src/nest/feeds/feeds.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, } from '../../nest-mcp'; -import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { getAppUrl } from '../../app-config'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Trips } from '../../db/entities/Trips.entity'; @@ -68,7 +68,7 @@ export class FeedsMcp { name: 'get_trip_calendar_feed', description: 'Get the subscribable calendar feed URL of one trip, or null when the feed is switched off. This is a live subscription a calendar app re-reads hourly, so it keeps up with the itinerary. Prefer export_trip_ics when the user wants a one-off .ics file to import once and be done.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'share' }, @@ -83,7 +83,7 @@ export class FeedsMcp { name: 'enable_trip_calendar_feed', description: 'Switch on the subscribable calendar feed of one trip and return its URL. Safe to repeat: a trip that already has a feed keeps the URL it has, so subscriptions somebody already set up survive. The URL carries a secret token and asks for no login, so whoever holds it can read the trip\'s dates.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'share' }, @@ -98,7 +98,7 @@ export class FeedsMcp { name: 'rotate_trip_calendar_feed', description: 'Issue a fresh URL for one trip\'s calendar feed. Every calendar subscribed to the old URL silently stops updating, so use this when the old link leaked or the user asked to cut it off, not to switch the feed on: enable_trip_calendar_feed does that without breaking anything.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'share' }, @@ -113,7 +113,7 @@ export class FeedsMcp { name: 'disable_trip_calendar_feed', description: 'Switch off one trip\'s calendar feed. The URL stops resolving and every subscription to it breaks. Calling enable_trip_calendar_feed afterwards hands out a different URL, so everybody has to subscribe again.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'trips', mode: 'share' }, diff --git a/server/src/nest/files/files.mcp.ts b/server/src/nest/files/files.mcp.ts index e73ad3041..2eec60ba3 100644 --- a/server/src/nest/files/files.mcp.ts +++ b/server/src/nest/files/files.mcp.ts @@ -4,7 +4,7 @@ import { TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, errorResult, ok, } from '../../nest-mcp'; -import { fileLinkRequestSchema, fileUpdateRequestSchema } from '@trek/shared'; +import { idSchema, fileLinkRequestSchema, fileUpdateRequestSchema } from '@trek/shared'; import type { FileLinkRequest, FileUpdateRequest } from '@trek/shared'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; @@ -70,7 +70,7 @@ export class FilesMcp { name: 'list_trip_files', description: 'List the documents on a trip: name, type, size, who uploaded it, what it is attached to, whether it is starred, and when it was moved to the trash. This is the way to find a file ID; to read what is inside one, follow up with read_trip_file. Set trash to true to list the trip\'s deleted files instead of its live ones.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, trash: z.boolean().optional().default(false).describe('List the trash instead of the live files'), }, annotations: TOOL_ANNOTATIONS_READONLY, @@ -85,8 +85,8 @@ export class FilesMcp { name: 'read_trip_file', description: `Read what is inside one uploaded document, e.g. a booking confirmation or a ticket. Text files come back as readable text, anything else base64-encoded, with "encoding" saying which. Files over ${CONTENT_MAX_MB} MB are refused outright, so point the user at the file in TREK rather than retrying. Reading contents is a separate permission from listing files, so this can be refused on a trip where list_trip_files works.`, inputSchema: { - tripId: z.number().int().positive(), - fileId: z.number().int().positive().describe('File ID from list_trip_files'), + tripId: idSchema, + fileId: idSchema.describe('File ID from list_trip_files'), }, annotations: TOOL_ANNOTATIONS_READONLY, // Not files:read. Listing what a trip carries and reading the bytes of a @@ -119,12 +119,12 @@ export class FilesMcp { name: 'upload_trip_file', description: `Add a document to a trip, e.g. a booking confirmation PDF or a ticket, and optionally attach it to a booking or a place in the same call. Pass the bytes base64-encoded in content, up to ${UPLOAD_MAX_MB} MB decoded. The file name has to carry an extension the trip's file manager accepts; the type is derived from it. Returns the new file, whose ID works with read_trip_file and link_trip_file.`, inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, filename: z.string().trim().min(1).max(255).describe('File name including its extension, e.g. "hotel-confirmation.pdf"'), content: z.string().min(1).max(Math.ceil(UPLOAD_MAX / 3) * 4).describe('The file bytes, base64-encoded'), description: z.string().max(1000).optional().describe('Free-text description'), - reservation_id: z.number().int().positive().optional().describe('Booking on the same trip to attach the file to'), - place_id: z.number().int().positive().optional().describe('Place on the same trip to attach the file to'), + reservation_id: idSchema.optional().describe('Booking on the same trip to attach the file to'), + place_id: idSchema.optional().describe('Place on the same trip to attach the file to'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'files', mode: 'write' }, @@ -163,8 +163,8 @@ export class FilesMcp { name: 'update_trip_file', description: 'Set a file\'s description and attach it to a booking or a place. Fields left out keep their current value, null detaches. place_id and reservation_id are the file\'s primary attachment, the one the file manager shows next to it. To attach one document to several bookings or places at once, use link_trip_file instead.', inputSchema: { - tripId: z.number().int().positive(), - fileId: z.number().int().positive(), + tripId: idSchema, + fileId: idSchema, description: fileUpdateRequestSchema.shape.description.describe('Free-text description, or an empty string to clear it'), place_id: fileUpdateRequestSchema.shape.place_id.describe('Place on the same trip to attach the file to, or null to detach it'), reservation_id: fileUpdateRequestSchema.shape.reservation_id.describe('Booking on the same trip to attach the file to, or null to detach it'), @@ -202,8 +202,8 @@ export class FilesMcp { name: 'link_trip_file', description: 'Attach a file to one more booking, place or day assignment on the same trip, keeping every attachment it already has. Prefer update_trip_file when the document belongs to a single booking or place; use this one for a document that covers several, such as one group ticket or one rental agreement. Returns every link the file now carries.', inputSchema: { - tripId: z.number().int().positive(), - fileId: z.number().int().positive(), + tripId: idSchema, + fileId: idSchema, reservation_id: fileLinkRequestSchema.shape.reservation_id.describe('Booking on the same trip'), assignment_id: fileLinkRequestSchema.shape.assignment_id.describe('Day assignment (a place scheduled on a specific day) on the same trip'), place_id: fileLinkRequestSchema.shape.place_id.describe('Place on the same trip'), @@ -235,9 +235,9 @@ export class FilesMcp { name: 'unlink_trip_file', description: 'Remove one attachment between a file and a booking, place or day assignment. The file itself stays on the trip and its other attachments are untouched. Take linkId from list_trip_file_links, it is not the booking or place ID.', inputSchema: { - tripId: z.number().int().positive(), - fileId: z.number().int().positive(), - linkId: z.number().int().positive().describe('Link ID from list_trip_file_links'), + tripId: idSchema, + fileId: idSchema, + linkId: idSchema.describe('Link ID from list_trip_file_links'), }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'files', mode: 'write' }, @@ -260,8 +260,8 @@ export class FilesMcp { name: 'list_trip_file_links', description: 'List everything one file is attached to: bookings (with their title), places and day assignments, each with the linkId that unlink_trip_file needs. list_trip_files already reports the linked booking and place IDs, so reach for this one when you need the link IDs themselves.', inputSchema: { - tripId: z.number().int().positive(), - fileId: z.number().int().positive(), + tripId: idSchema, + fileId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'files', mode: 'read' }, diff --git a/server/src/nest/integrations/dawarich.mcp.ts b/server/src/nest/integrations/dawarich.mcp.ts index fcb6f8ff0..66f006bff 100644 --- a/server/src/nest/integrations/dawarich.mcp.ts +++ b/server/src/nest/integrations/dawarich.mcp.ts @@ -6,6 +6,7 @@ import { errorResult, ok, } from '../../nest-mcp'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; @@ -57,7 +58,7 @@ export class DawarichMcp { description: "List the stays TREK pulled from the caller's connected Dawarich instance and is holding for review: where they were, when they arrived and left, how long they stayed, and which trip the stay falls into. Each entry carries the id the accept tools take. Use it to answer what someone actually did on a trip, or to fill a travel journal from what was recorded rather than from memory. Nothing here has been added to a trip yet — accepting is a separate, explicit step.", inputSchema: { - tripId: z.number().int().positive().optional().describe('Only stays that fall inside this trip'), + tripId: idSchema.optional().describe('Only stays that fall inside this trip'), state: z.enum(['new', 'accepted', 'dismissed']).optional() .describe('Default: every state. "new" is the review backlog.'), limit: z.number().int().min(1).max(MAX_LIMIT).optional() @@ -90,9 +91,9 @@ export class DawarichMcp { description: 'Turn a reviewed Dawarich stay into a place on a trip, optionally pinned to one of its days. Call list_dawarich_suggestions first for the id. Correct the name or the coordinates in the same call if the detector got them wrong — that is what the review step is for. Use this when the stay is somewhere worth having on the itinerary; use accept_dawarich_suggestion_as_journal_entry when it is a moment worth writing about instead.', inputSchema: { - suggestionId: z.number().int().positive(), - tripId: z.number().int().positive().optional().describe('Defaults to the trip the stay fell into'), - dayId: z.number().int().positive().optional().describe('Pin it to this day; omitted leaves it unplanned'), + suggestionId: idSchema, + tripId: idSchema.optional().describe('Defaults to the trip the stay fell into'), + dayId: idSchema.optional().describe('Pin it to this day; omitted leaves it unplanned'), name: z.string().trim().min(1).max(255).optional(), notes: z.string().max(5000).optional(), lat: z.number().min(-90).max(90).optional(), @@ -115,8 +116,8 @@ export class DawarichMcp { description: "Turn a reviewed Dawarich stay into a dated entry in a travel journal. Call list_dawarich_suggestions first for the id. The stay's own date, arrival time, name and coordinates prefill the entry; pass a title or a story to write it properly rather than leaving the detector's label. Use this to fill in a journal after a trip from what was actually recorded.", inputSchema: { - suggestionId: z.number().int().positive(), - journalId: z.number().int().positive().describe('The journey the entry is added to'), + suggestionId: idSchema, + journalId: idSchema.describe('The journey the entry is added to'), name: z.string().trim().min(1).max(255).optional().describe('Entry title; defaults to the stay name'), notes: z.string().max(5000).optional().describe('The story text'), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), @@ -139,8 +140,8 @@ export class DawarichMcp { description: "Tick a bucket-list wish off because a recorded stay proves the caller got there. Call list_dawarich_suggestions first: a stay that sits on top of a wish already carries its id as matchedBucketListItemId. Use it when someone asks which of the places they wanted to see they have actually reached.", inputSchema: { - suggestionId: z.number().int().positive(), - bucketListItemId: z.number().int().positive().optional() + suggestionId: idSchema, + bucketListItemId: idSchema.optional() .describe('Defaults to the wish the stay was matched to'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -161,7 +162,7 @@ export class DawarichMcp { description: 'Take a Dawarich stay out of the review list without adding it anywhere, or put a dismissed one back. Use it for the stays a detector produces that are not places anyone went — a traffic jam, a car park, the office.', inputSchema: { - suggestionId: z.number().int().positive(), + suggestionId: idSchema, state: z.enum(['dismissed', 'new']).optional().describe('Default: dismissed'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -182,7 +183,7 @@ export class DawarichMcp { description: "Fetch the route actually recorded during a trip, grouped by local day, straight from the caller's Dawarich instance. Each day carries its segments with start and end times and, where Dawarich classified it, how it was travelled. Use it to answer what route someone really took, or how a day's movement compares with what was planned. Nothing is stored in TREK — this is a live read of the recording.", inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this first day'), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this last day'), }, diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 6c8a81071..dbeb3acc5 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -5,6 +5,7 @@ import { ok, type McpContext, } from '../../nest-mcp'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { JourneyDomainService } from './journey-domain.service'; import { JourneyShareService } from './journey-share.service'; @@ -128,7 +129,7 @@ export class JourneyMcp { @Tool({ name: 'get_journey', description: 'Get a full journey including entries, contributors, and linked trips.', - inputSchema: { journeyId: z.number().int().positive() }, + inputSchema: { journeyId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, @@ -143,7 +144,7 @@ export class JourneyMcp { name: 'get_journey_stats', description: 'What a journey adds up to: distance travelled in metres, calendar days spanned, countries in visit order, the furthest point reached, and entry, photo and place counts. Stops the traveller switched off with update_journey_entry count towards none of those and are listed under excluded instead. Prefer this over get_journey whenever the question is about totals, since the stats get_journey carries are three counts and nothing else.', inputSchema: { - journeyId: z.number().int().positive(), + journeyId: idSchema, include_route: z.boolean().optional().describe('Also return the route itself, up to 400 stops with coordinates. Off by default: the totals and the country list do not need it.'), }, annotations: TOOL_ANNOTATIONS_READONLY, @@ -164,7 +165,7 @@ export class JourneyMcp { @Tool({ name: 'list_journey_entries', description: 'List all entries in a journey.', - inputSchema: { journeyId: z.number().int().positive() }, + inputSchema: { journeyId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, @@ -177,7 +178,7 @@ export class JourneyMcp { @Tool({ name: 'list_journey_contributors', description: 'List all contributors (owner and collaborators) of a journey.', - inputSchema: { journeyId: z.number().int().positive() }, + inputSchema: { journeyId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: journeyAddonOn, access: { group: 'journey', mode: 'read' }, @@ -220,7 +221,7 @@ export class JourneyMcp { inputSchema: { title: z.string().min(1).max(200), subtitle: z.string().max(300).optional(), - trip_ids: z.array(z.number().int().positive()).optional(), + trip_ids: z.array(idSchema).optional(), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: journeyAddonOn, @@ -240,7 +241,7 @@ export class JourneyMcp { name: 'update_journey', description: "Update an existing journey's title, subtitle, cover, or status. Owner only. status_override sets the state shown for the journey (draft, live, completed) instead of deriving it from the linked trips' dates; null goes back to the dates.", inputSchema: { - journeyId: z.number().int().positive(), + journeyId: idSchema, title: z.string().min(1).max(200).optional(), subtitle: z.string().max(300).optional(), status: z.enum(['draft', 'active', 'completed', 'archived']).optional(), @@ -270,7 +271,7 @@ export class JourneyMcp { name: 'restore_journey_suggestions', description: 'Bring back every trip-derived suggestion that was dismissed from this journey. Answers with how many came back.', inputSchema: { - journeyId: z.number().int().positive(), + journeyId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, @@ -285,7 +286,7 @@ export class JourneyMcp { @Tool({ name: 'delete_journey', description: 'Delete a journey. Owner only — this cannot be undone.', - inputSchema: { journeyId: z.number().int().positive() }, + inputSchema: { journeyId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, @@ -298,7 +299,7 @@ export class JourneyMcp { @Tool({ name: '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() }, + inputSchema: { journeyId: idSchema, tripId: idSchema }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, @@ -311,7 +312,7 @@ export class JourneyMcp { @Tool({ name: 'remove_journey_trip', description: 'Unlink a trip from a journey. Owner only.', - inputSchema: { journeyId: z.number().int().positive(), tripId: z.number().int().positive() }, + inputSchema: { journeyId: idSchema, tripId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, @@ -326,7 +327,7 @@ export class JourneyMcp { name: 'create_journey_entry', description: 'Create a new entry in a journey. Give location_lat/location_lng whenever the place is known, otherwise the entry is text-only and never appears on the journey map or in its distance.', inputSchema: { - journeyId: z.number().int().positive(), + journeyId: idSchema, 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(), @@ -367,7 +368,7 @@ export class JourneyMcp { name: 'update_journey_entry', description: 'Update an existing journey entry: its text, date, place, coordinates, weather, tags, verdict, visibility, whether it is a draft, or whether it counts as a stop. Fields left out keep their value; pass null to clear one. To move an entry within its day use reorder_journey_entries rather than setting sort_order here.', inputSchema: { - entryId: z.number().int().positive(), + entryId: idSchema, title: z.string().max(300).nullable().optional(), story: z.string().nullable().optional(), entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), @@ -411,7 +412,7 @@ export class JourneyMcp { @Tool({ name: 'delete_journey_entry', description: 'Delete a journey entry.', - inputSchema: { entryId: z.number().int().positive() }, + inputSchema: { entryId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, @@ -425,8 +426,8 @@ export class JourneyMcp { name: '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()), + journeyId: idSchema, + orderedIds: z.array(idSchema), }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, @@ -442,8 +443,8 @@ export class JourneyMcp { name: 'reorder_journey_entry_photos', description: 'Put the photos of one journey entry in a new order. Pass every photo id the entry holds (the journey photo ids list_journey_entries returns), each once, first photo first. The first one is the entry cover.', inputSchema: { - entryId: z.number().int().positive(), - orderedIds: z.array(z.number().int().positive()).min(1).max(500), + entryId: idSchema, + orderedIds: z.array(idSchema).min(1).max(500), }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, @@ -460,8 +461,8 @@ export class JourneyMcp { name: 'add_journey_contributor', description: 'Add a contributor to a journey. Owner only.', inputSchema: { - journeyId: z.number().int().positive(), - targetUserId: z.number().int().positive(), + journeyId: idSchema, + targetUserId: idSchema, role: z.enum(['editor', 'viewer']), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -480,8 +481,8 @@ export class JourneyMcp { name: '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(), + journeyId: idSchema, + targetUserId: idSchema, role: z.enum(['editor', 'viewer']), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -500,8 +501,8 @@ export class JourneyMcp { name: 'remove_journey_contributor', description: 'Remove a contributor from a journey. Owner only.', inputSchema: { - journeyId: z.number().int().positive(), - targetUserId: z.number().int().positive(), + journeyId: idSchema, + targetUserId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: journeyAddonOn, @@ -516,7 +517,7 @@ export class JourneyMcp { name: 'update_journey_preferences', description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).', inputSchema: { - journeyId: z.number().int().positive(), + journeyId: idSchema, hide_skeletons: z.boolean().optional(), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -537,8 +538,8 @@ export class JourneyMcp { name: 'add_journey_provider_photos', description: 'Attach photos from a connected library (Immich or Synology Photos) to a journey: to one entry when entryId is given, otherwise to the journey gallery only. Find the asset ids first with search_provider_photos or list_provider_album_photos. No image data passes through here, the journey stores a reference and the app fetches the picture, so this also works for photos far too large to hand to a model. An asset already attached is skipped rather than duplicated, which makes re-running the same call safe.', inputSchema: { - journeyId: z.number().int().positive(), - entryId: z.number().int().positive().optional().describe('Attach to this entry, which must belong to journeyId. The photo lands in the journey gallery either way; omitting this adds it to the gallery alone'), + journeyId: idSchema, + entryId: idSchema.optional().describe('Attach to this entry, which must belong to journeyId. The photo lands in the journey gallery either way; omitting this adds it to the gallery alone'), provider: PHOTO_PROVIDER.describe('The library the asset ids came from'), asset_ids: z.array(z.string().min(1)).min(1).max(MAX_PROVIDER_PHOTOS_PER_CALL).describe('Provider asset ids, as returned by the search and album tools'), media_types: z.array(z.enum(['image', 'video'])).optional().describe('Parallel to asset_ids; anything not named here counts as an image'), @@ -591,7 +592,7 @@ export class JourneyMcp { @Tool({ name: 'get_journey_share_link', description: 'Get the current public share link for a journey. Owner only. Returns null if none exists.', - inputSchema: { journeyId: z.number().int().positive() }, + inputSchema: { journeyId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, when: journeyAddonOn, access: { group: 'journey', mode: 'share' }, @@ -608,7 +609,7 @@ export class JourneyMcp { name: 'create_journey_share_link', description: 'Create or update the public share link for a journey. Owner only. Flags left out keep their current value on an existing link; a new link defaults to timeline/gallery/map on.', inputSchema: { - journeyId: z.number().int().positive(), + journeyId: idSchema, share_timeline: z.boolean().optional(), share_gallery: z.boolean().optional(), share_map: z.boolean().optional(), @@ -627,7 +628,7 @@ export class JourneyMcp { @Tool({ name: 'delete_journey_share_link', description: 'Revoke the public share link for a journey. Owner only.', - inputSchema: { journeyId: z.number().int().positive() }, + inputSchema: { journeyId: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, when: journeyAddonOn, access: { group: 'journey', mode: 'share' }, diff --git a/server/src/nest/notifications/notifications.mcp.ts b/server/src/nest/notifications/notifications.mcp.ts index e4a0bf57a..1ee7d1afb 100644 --- a/server/src/nest/notifications/notifications.mcp.ts +++ b/server/src/nest/notifications/notifications.mcp.ts @@ -4,6 +4,7 @@ import { ok, } from '../../nest-mcp'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { NotificationsService } from './notifications.service'; function jsonContent(uri: string, data: unknown) { @@ -38,7 +39,7 @@ export class NotificationsMcp { name: 'list_notifications', description: 'List in-app notifications for the current user.', inputSchema: { - limit: z.number().int().positive().optional().default(20), + limit: idSchema.optional().default(20), offset: z.number().int().min(0).optional().default(0), unread_only: z.boolean().optional().default(false), }, @@ -69,7 +70,7 @@ export class NotificationsMcp { name: 'mark_notification_read', description: 'Mark a single notification as read.', inputSchema: { - notificationId: z.number().int().positive(), + notificationId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'notifications', mode: 'write' }, @@ -84,7 +85,7 @@ export class NotificationsMcp { name: 'mark_notification_unread', description: 'Mark a single notification as unread.', inputSchema: { - notificationId: z.number().int().positive(), + notificationId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'notifications', mode: 'write' }, diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index 92c49f912..e2f70d440 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -17,6 +17,7 @@ import { packingUpdateBagRequestSchema, packingUpdateItemRequestSchema, type PackingVisibility, + idSchema, } from '@trek/shared'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; @@ -60,7 +61,7 @@ export class PackingMcp { name: 'create_packing_item', description: 'Add an item to the packing checklist for a trip. It lands on the common list everyone shares unless visibility says otherwise; use set_packing_item_sharing to move an existing item between those tiers.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, name: z.string().min(1).max(200), category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'), bag_id: packingCreateItemRequestSchema.shape.bag_id.describe('Bag to pack the item into (ids come from list_packing_bags)'), @@ -98,8 +99,8 @@ export class PackingMcp { name: 'toggle_packing_item', description: 'Check or uncheck a packing item.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, checked: z.boolean(), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -117,8 +118,8 @@ export class PackingMcp { name: 'delete_packing_item', description: 'Remove an item from the packing checklist.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: packingAddonOn, @@ -135,8 +136,8 @@ export class PackingMcp { name: 'update_packing_item', description: 'Change a packing item: rename it, recategorise it, move it into a bag, set how many are needed, count how many are already packed, record its weight, or flip it between the common list and your own. Ticking it off is toggle_packing_item; choosing who a private item is shared with is set_packing_item_sharing.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, name: z.string().min(1).max(200).optional(), category: z.string().max(100).optional(), bag_id: packingUpdateItemRequestSchema.shape.bag_id.describe('Bag to pack the item into (ids come from list_packing_bags); null takes it out of its bag'), @@ -165,8 +166,8 @@ export class PackingMcp { name: 'set_packing_item_sharing', description: 'Move an existing packing item between the three sharing tiers: the common list the whole trip pools into, the owner\'s own list, or shared with named trip members. Only the item\'s owner may change this. Everything else about an item is update_packing_item.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, visibility: packingSetSharingRequestSchema.shape.visibility.describe("'common' puts the item in the group pool, 'personal' keeps it to the owner, 'shared' covers the people in recipient_ids"), recipient_ids: packingSetSharingRequestSchema.shape.recipient_ids.describe("For 'shared': the trip members the item is brought for. Ids outside the trip roster are dropped, and any previous recipients are replaced"), }, @@ -197,8 +198,8 @@ export class PackingMcp { name: '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'), + tripId: idSchema, + orderedIds: z.array(idSchema).describe('Packing item IDs in desired order'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: packingAddonOn, @@ -216,7 +217,7 @@ export class PackingMcp { name: 'list_packing_bags', description: 'List all packing bags for a trip.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, when: packingAddonOn, @@ -232,7 +233,7 @@ export class PackingMcp { name: 'create_packing_bag', description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, name: z.string().min(1).max(100), color: z.string().optional(), weight_limit_grams: packingCreateBagRequestSchema.shape.weight_limit_grams.describe('Allowance in grams the bag is measured against (the fill bar)'), @@ -255,8 +256,8 @@ export class PackingMcp { name: 'update_packing_bag', description: 'Rename or recolor a packing bag, give it a weight limit, or hand it to one traveller. Who else packs into it is set_bag_members.', inputSchema: { - tripId: z.number().int().positive(), - bagId: z.number().int().positive(), + tripId: idSchema, + bagId: idSchema, name: z.string().optional(), color: z.string().optional(), weight_limit_grams: packingUpdateBagRequestSchema.shape.weight_limit_grams.describe('Allowance in grams the bag is measured against (the fill bar); null lifts the limit'), @@ -292,8 +293,8 @@ export class PackingMcp { name: '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(), + tripId: idSchema, + bagId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: packingAddonOn, @@ -316,9 +317,9 @@ export class PackingMcp { name: '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()), + tripId: idSchema, + bagId: idSchema, + userIds: z.array(idSchema), }, annotations: TOOL_ANNOTATIONS_WRITE, when: packingAddonOn, @@ -337,7 +338,7 @@ export class PackingMcp { name: 'get_packing_category_assignees', description: 'Get which trip members are assigned to each packing category.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, when: packingAddonOn, @@ -353,9 +354,9 @@ export class PackingMcp { name: 'set_packing_category_assignees', description: 'Assign trip members to a packing category.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, categoryName: z.string().min(1).max(100), - userIds: z.array(z.number().int().positive()), + userIds: z.array(idSchema), }, annotations: TOOL_ANNOTATIONS_WRITE, when: packingAddonOn, @@ -373,8 +374,8 @@ export class PackingMcp { name: '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(), + tripId: idSchema, + templateId: idSchema, }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: packingAddonOn, @@ -394,7 +395,7 @@ export class PackingMcp { name: 'list_packing_templates', description: 'List the reusable packing templates (id, name, item count) so one can be applied with apply_packing_template.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, when: packingAddonOn, @@ -409,7 +410,7 @@ export class PackingMcp { name: 'save_packing_template', description: 'Save the current packing list as a reusable template. Returns the new template (id, name, category/item counts). Admin only.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, templateName: z.string().min(1).max(100), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -430,7 +431,7 @@ export class PackingMcp { name: 'delete_packing_template', description: 'Delete a reusable packing template. Templates are global, so deletion is admin only.', inputSchema: { - templateId: z.number().int().positive(), + templateId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: packingAddonOn, @@ -448,11 +449,11 @@ export class PackingMcp { name: 'bulk_import_packing', description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, items: z.array(z.strictObject({ name: z.string().min(1).max(200), category: z.string().optional(), - quantity: z.number().int().positive().optional(), + quantity: idSchema.optional(), bag: z.string().max(100).optional().describe('Bag name to assign the item to; created if it does not exist'), weight_grams: z.number().nonnegative().optional(), checked: z.boolean().optional(), diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 444e8c056..22700836c 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -17,6 +17,7 @@ import { roadtripGpxImportSchema, type RoadtripGpxImport, type RoadtripStopType, + idSchema, } from '@trek/shared'; import { z } from 'zod'; import { AssignmentsService } from '../assignments/assignments.service'; @@ -70,13 +71,13 @@ export class PlacesMcp { name: 'create_place', description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, osm_id or amap_poi_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, name: z.string().min(1).max(200), description: z.string().max(2000).optional(), lat: z.number().optional(), lng: z.number().optional(), address: z.string().max(500).optional(), - category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), + category_id: idSchema.optional().describe('Category ID — use list_categories to see available options'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'), @@ -115,14 +116,14 @@ export class PlacesMcp { name: 'create_and_assign_place', description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive().describe('Day to assign the place to'), + tripId: idSchema, + dayId: idSchema.describe('Day to assign the place to'), name: z.string().min(1).max(200), description: z.string().max(2000).optional(), lat: z.number().optional(), lng: z.number().optional(), address: z.string().max(500).optional(), - category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), + category_id: idSchema.optional().describe('Category ID — use list_categories to see available options'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), @@ -173,14 +174,14 @@ export class PlacesMcp { name: 'update_place', description: 'Update an existing place in a trip.', inputSchema: { - tripId: z.number().int().positive(), - placeId: z.number().int().positive(), + tripId: idSchema, + placeId: idSchema, name: z.string().min(1).max(200).optional(), description: z.string().max(2000).optional(), lat: z.number().optional(), lng: z.number().optional(), address: z.string().max(500).optional(), - category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'), + category_id: idSchema.optional().describe('Category ID — use list_categories'), price: z.number().optional(), currency: z.string().length(3).optional(), place_time: z.string().max(50).nullable().optional().describe('Scheduled time (e.g. "09:00"); null clears it'), @@ -228,9 +229,9 @@ export class PlacesMcp { name: 'set_place_image_from_file', description: 'Use a picture already attached in the trip (a jpg, png, gif or webp from list_files) as the image of the place. The file is copied, so deleting the attachment later keeps the image.', inputSchema: { - tripId: z.number().int().positive(), - placeId: z.number().int().positive(), - fileId: z.number().int().positive().describe('Id of a trip file that is an image'), + tripId: idSchema, + placeId: idSchema, + fileId: idSchema.describe('Id of a trip file that is an image'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'places', mode: 'write' }, @@ -254,8 +255,8 @@ export class PlacesMcp { name: 'rate_place', description: "Set or clear the current user's 1-5 star rating on a trip place (#1435). Every trip member rates independently; the place shows the average. Omit rating (or pass null) to remove the user's vote. Use the ratings to capture the user's preferences and shape the itinerary around highly-rated places.", inputSchema: { - tripId: z.number().int().positive(), - placeId: z.number().int().positive(), + tripId: idSchema, + placeId: idSchema, rating: z.number().int().min(1).max(5).nullable().optional().describe('1-5 stars; null/omitted clears the vote'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -277,8 +278,8 @@ export class PlacesMcp { name: 'delete_place', description: 'Permanently delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. If the place is a Tour, its route and editable waypoints are also permanently deleted. Warn the user before calling: this cannot be undone.', inputSchema: { - tripId: z.number().int().positive(), - placeId: z.number().int().positive(), + tripId: idSchema, + placeId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'places', mode: 'write' }, @@ -310,7 +311,7 @@ export class PlacesMcp { name: 'list_places', description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, search: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), @@ -366,7 +367,7 @@ export class PlacesMcp { name: '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(), + tripId: idSchema, url: z.string().url().describe('Publicly shared Google Maps list URL, a Google Maps directions link (/maps/dir/...), or a Naver Maps list URL. A directions link is read straight out of the URL: its stops become places in driving order, and the ones written as names are geocoded.'), source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'), enrich: placeImportListRequestSchema.shape.enrich.describe('Re-resolve every imported place through the Places API afterwards to fill in photo, address, website and phone (#886). Needs a Google Maps key on the instance, costs a lookup per place, and runs in the background: the tool returns the bare import and the places fill in over the websocket. Off by default'), @@ -422,7 +423,7 @@ export class PlacesMcp { name: 'export_trip_gpx', description: 'Export a trip as GPX text: its places as waypoints, any imported routes as tracks, and each planned day as a route in visiting order. This is the format handhelds and offline map apps (Organic Maps, OsmAnd, Garmin) read. Prefer export_trip_ics when the user wants the itinerary in a calendar instead.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, waypoints: z.boolean().optional().default(true).describe('Write every place with coordinates as a '), tracks: z.boolean().optional().default(true).describe('Write places that carry an imported route geometry as a '), dayRoutes: z.boolean().optional().default(true).describe('Write each planned day as a through its stops in order'), @@ -448,8 +449,8 @@ export class PlacesMcp { name: 'bulk_delete_places', description: 'Permanently delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Any Tours in the selection also lose their routes and editable waypoints. Warn the user before calling: this cannot be undone.', inputSchema: { - tripId: z.number().int().positive(), - placeIds: z.array(z.number().int().positive()).min(1).max(200), + tripId: idSchema, + placeIds: z.array(idSchema).min(1).max(200), }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'places', mode: 'write' }, @@ -481,15 +482,15 @@ export class PlacesMcp { name: 'bulk_update_places', description: 'Update many places in a trip at once, applying the SAME field values to every listed place. Use this for sweeping edits — e.g. re-categorising a batch of POIs (set category_id for 80 places) — in a single call instead of one update_place per place. Only the fields you set are changed; everything else on each place is preserved. Use list_categories for category_id.', inputSchema: { - tripId: z.number().int().positive(), - placeIds: z.array(z.number().int().positive()).min(1).max(500).describe('IDs of the places to update (from list_places)'), - category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'), + tripId: idSchema, + placeIds: z.array(idSchema).min(1).max(500).describe('IDs of the places to update (from list_places)'), + category_id: idSchema.optional().describe('Category ID — use list_categories'), price: z.number().optional(), currency: z.string().length(3).optional(), transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(), place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'), end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'), - duration_minutes: z.number().int().positive().optional(), + duration_minutes: idSchema.optional(), notes: z.string().max(2000).optional(), website: placeWebsiteSchema.optional(), phone: z.string().max(50).optional(), diff --git a/server/src/nest/plugins/contributions/plugin-search.mcp.ts b/server/src/nest/plugins/contributions/plugin-search.mcp.ts index cf7c4195c..cfdd175f2 100644 --- a/server/src/nest/plugins/contributions/plugin-search.mcp.ts +++ b/server/src/nest/plugins/contributions/plugin-search.mcp.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext, type McpTextResult } from '../../../nest-mcp'; import { pluginsEnabled } from '../kill-switch'; import { PluginHooks } from '../plugin-hooks.service'; @@ -38,7 +39,7 @@ export class PluginSearchMcp { .optional() .describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'), lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'), - limit: z.number().int().positive().max(20).optional().describe('How many results per provider (default 10, capped at 20)'), + limit: idSchema.max(20).optional().describe('How many results per provider (default 10, capped at 20)'), }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'places', mode: 'read' }, diff --git a/server/src/nest/plugins/contributions/trip-warnings.mcp.ts b/server/src/nest/plugins/contributions/trip-warnings.mcp.ts index 10c18d704..706c49527 100644 --- a/server/src/nest/plugins/contributions/trip-warnings.mcp.ts +++ b/server/src/nest/plugins/contributions/trip-warnings.mcp.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext, type McpTextResult } from '../../../nest-mcp'; import { noAccess } from '../../../mcp/tools/_shared'; import { pluginsEnabled } from '../kill-switch'; @@ -47,7 +47,7 @@ export class TripWarningsMcp { name: 'get_trip_warnings', description: 'Problems installed plugins report about a trip: a plugin flagging that something is wrong with the itinerary, such as an accommodation with no check-out, a day that cannot be travelled in the time it allows, or a place closed on the day it is planned for. get_trip_summary returns the trip\'s stored data and never these verdicts, so call this as well before reviewing a trip, reporting on it, or telling the user it looks fine. Returns an empty list when no installed plugin contributes warnings, which is the normal case.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, diff --git a/server/src/nest/reservation-import/reservation-import.mcp.ts b/server/src/nest/reservation-import/reservation-import.mcp.ts index fd51306e3..dd97830e0 100644 --- a/server/src/nest/reservation-import/reservation-import.mcp.ts +++ b/server/src/nest/reservation-import/reservation-import.mcp.ts @@ -3,8 +3,7 @@ import { TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, errorResult, ok, } from '../../nest-mcp'; -import { z } from 'zod'; -import { airtrailImportSchema } from '@trek/shared'; +import { idSchema, airtrailImportSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; @@ -53,7 +52,7 @@ export class ReservationImportMcp { name: 'import_airtrail_flights', description: 'Import flights from the caller\'s connected AirTrail account into a trip as flight bookings, keeping them linked to AirTrail for two-way sync. Get the ids from list_airtrail_flights first; this tool only accepts ids that account already holds, so it cannot invent a flight. Prefer it over create_transport whenever the flight is already recorded in AirTrail: the route, times, airline and aircraft come across without retyping. Flights already on the trip are reported as skipped rather than duplicated.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, flightIds: airtrailImportSchema.shape.flightIds .describe(`AirTrail flight ids from list_airtrail_flights, at most ${MAX_MCP_AIRTRAIL_FLIGHTS} per call`), connections: airtrailImportSchema.shape.connections diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index aaa4e00ab..84816aceb 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -13,7 +13,7 @@ import { DaysService } from '../days/days.service'; import { findByIata } from '../airports/airports.data'; import type { EndpointInput } from './reservations.service'; import { AssignmentsService } from '../assignments/assignments.service'; -import { transportLegInputSchema, reservationUrlSchema, type TransportLegInput } from '@trek/shared'; +import { idSchema, transportLegInputSchema, reservationUrlSchema, type TransportLegInput } from '@trek/shared'; import { RESERVATION_METADATA } from '../../db/json-columns'; import { decodeJson } from '../../utils/json-column'; @@ -329,7 +329,7 @@ export class ReservationsMcp { name: 'create_reservation', description: 'Recommend a reservation for a trip. Created as pending, so the user must confirm it. For anything travelled ON (flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other) 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/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, title: z.string().min(1).max(200), type: z.enum(BOOKING_TYPES).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), @@ -338,13 +338,13 @@ export class ReservationsMcp { location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), - day_id: z.number().int().positive().optional(), - place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'), - start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'), - end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'), + day_id: idSchema.optional(), + place_id: idSchema.optional().describe('Hotel place to link (hotel type only)'), + start_day_id: idSchema.optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'), + end_day_id: idSchema.optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'), check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'), - assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'), + assignment_id: idSchema.optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'), price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'), budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'), }, @@ -402,8 +402,8 @@ export class ReservationsMcp { name: 'update_reservation', description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For anything travelled ON (flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other) use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.', inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), + tripId: idSchema, + reservationId: idSchema, title: z.string().min(1).max(200).optional(), type: z.enum(BOOKING_TYPES).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), @@ -413,8 +413,8 @@ export class ReservationsMcp { confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'), - place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'), - assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'), + place_id: idSchema.nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'), + assignment_id: idSchema.nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'reservations', mode: 'write' }, @@ -455,8 +455,8 @@ export class ReservationsMcp { name: 'delete_reservation', description: 'Delete a reservation from a trip.', inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), + tripId: idSchema, + reservationId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'reservations', mode: 'write' }, @@ -478,9 +478,9 @@ export class ReservationsMcp { name: 'set_reservation_travelers', description: 'Set who is travelling on a booking, replacing the current list. Pass the user IDs of trip members or guests from list_trip_members; an empty array clears the list. Somebody who is not on the trip is ignored rather than added: use add_trip_member for a person with a TREK account, or create_trip_guest for one without.', inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), - user_ids: z.array(z.number().int().positive()).describe('User IDs of the travellers, from list_trip_members. Replaces the whole list; [] clears it.'), + tripId: idSchema, + reservationId: idSchema, + user_ids: z.array(idSchema).describe('User IDs of the travellers, from list_trip_members. Replaces the whole list; [] clears it.'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'reservations', mode: 'write' }, @@ -512,12 +512,12 @@ export class ReservationsMcp { name: 'reorder_reservations', description: 'Update the display order of reservations within a day.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, positions: z.array(z.strictObject({ - id: z.number().int().positive(), + id: idSchema, day_plan_position: z.number().int().min(0), })).describe('Array of { id, day_plan_position } pairs'), - dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'), + dayId: idSchema.optional().describe('Optionally scope the update to a specific day'), }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'reservations', mode: 'write' }, @@ -544,11 +544,11 @@ export class ReservationsMcp { name: 'link_hotel_accommodation', description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.', inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), - place_id: z.number().int().positive().describe('The hotel place to link'), - start_day_id: z.number().int().positive().describe('Check-in day ID'), - end_day_id: z.number().int().positive().describe('Check-out day ID'), + tripId: idSchema, + reservationId: idSchema, + place_id: idSchema.describe('The hotel place to link'), + start_day_id: idSchema.describe('Check-in day ID'), + end_day_id: idSchema.describe('Check-out day ID'), check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'), }, @@ -654,12 +654,12 @@ export class ReservationsMcp { name: 'create_transport', description: 'Create a transport booking for a trip: flight, train, bus, car, taxi, bicycle, cruise, ferry or transport_other. For scheduled public transit use create_transit_journey, which attaches the provider itinerary. Use endpoints[] to record origin/destination and intermediate stops; for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending, so confirm it with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, type: z.enum(CREATABLE_TRANSPORT_TYPES), 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)'), + start_day_id: idSchema.optional().describe('Departure day'), + end_day_id: idSchema.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(), @@ -755,13 +755,13 @@ export class ReservationsMcp { name: 'update_transport', description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. A per-segment booking reference lives on the leg (legs[].confirmation_number); the top-level one stays the booking reference. Use status "confirmed" to confirm.', inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), + tripId: idSchema, + reservationId: idSchema, type: z.enum(CREATABLE_TRANSPORT_TYPES).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)'), + start_day_id: idSchema.optional().describe('Departure day'), + end_day_id: idSchema.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(), @@ -875,8 +875,8 @@ export class ReservationsMcp { name: 'delete_transport', description: 'Delete a transport booking from a trip.', inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), + tripId: idSchema, + reservationId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'reservations', mode: 'write' }, diff --git a/server/src/nest/roadtrip/charging.mcp.ts b/server/src/nest/roadtrip/charging.mcp.ts index 5f0ae4892..55596cd5a 100644 --- a/server/src/nest/roadtrip/charging.mcp.ts +++ b/server/src/nest/roadtrip/charging.mcp.ts @@ -1,5 +1,4 @@ -import { chargingLookupSchema } from '@trek/shared'; -import { z } from 'zod'; +import { idSchema, chargingLookupSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { noAccess } from '../../mcp/tools/_shared'; import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; @@ -13,14 +12,14 @@ import { TripAccessService } from '../trip-membership/trip-access.service'; export class ChargingMcp { constructor(private readonly charging: ChargingService, private readonly tripsRepo: TripAccessService, readonly addons: AddonsService) {} @Tool({ name: 'get_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a saved charging stop. No API key required. Coverage varies by region and operator. Unknown or stale status is not available capacity. Prices can have conditions and extra fees and are not personalized roaming quotes.', - inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive() }, + inputSchema: { tripId: idSchema, placeId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess(); return answeringRefusals(async () => ok(await this.charging.read(tripId, placeId))); } @Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.', - inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape }, + inputSchema: { tripId: idSchema, ...chargingLookupSchema.shape }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) async lookup({ tripId, lat, lng, name }: { tripId: number; lat: number; lng: number; name: string }, ctx: McpContext) { if (!(await this.tripsRepo.findAccessible(tripId, ctx.userId))) return noAccess(); diff --git a/server/src/nest/roadtrip/day-boundaries.mcp.ts b/server/src/nest/roadtrip/day-boundaries.mcp.ts index e5a2a5839..2c15b481f 100644 --- a/server/src/nest/roadtrip/day-boundaries.mcp.ts +++ b/server/src/nest/roadtrip/day-boundaries.mcp.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { MAX_TRIP_DAYS, roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared'; +import { idSchema, MAX_TRIP_DAYS, roadtripDayBoundarySchema, type RoadtripDayBoundary } from '@trek/shared'; import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, ok, type McpContext } from '../../nest-mcp'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; @@ -25,7 +25,7 @@ export class DayBoundariesMcp { @Tool({ name: 'list_day_boundaries', description: 'List manual road trip day endings. They apply only with daily travel times enabled.', - inputSchema: { tripId: z.number().int().positive() }, + inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when, }) async list({ tripId }: { tripId: number }, ctx: McpContext) { @@ -36,7 +36,7 @@ export class DayBoundariesMcp { @Tool({ name: 'set_day_boundary', description: 'Override a road trip day ending at a visit or a fraction along the driving leg between consecutive visits. Daily travel times must be enabled in the planner. Fixed visit times stay protected and conflicts are shown. With roadtrip_hotel_bookends on, a boundary between two stops a booked night separates is ignored: the night ends the day. Pass null to restore the automatic day ending.', - inputSchema: { tripId: z.number().int().positive(), dayNumber: z.number().int().min(1).max(MAX_TRIP_DAYS), boundary: roadtripDayBoundarySchema.nullable() }, + inputSchema: { tripId: idSchema, dayNumber: z.number().int().min(1).max(MAX_TRIP_DAYS), boundary: roadtripDayBoundarySchema.nullable() }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when, }) async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) { diff --git a/server/src/nest/roadtrip/google-route.mcp.ts b/server/src/nest/roadtrip/google-route.mcp.ts index 6961e49ca..34b26835c 100644 --- a/server/src/nest/roadtrip/google-route.mcp.ts +++ b/server/src/nest/roadtrip/google-route.mcp.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { googleRouteImportSchema, googleRoutePreviewRequestSchema, googleRouteStopSchema, type GoogleRouteImport } from '@trek/shared'; +import { idSchema, googleRouteImportSchema, googleRoutePreviewRequestSchema, googleRouteStopSchema, type GoogleRouteImport } from '@trek/shared'; import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, type McpContext } from '../../nest-mcp'; import { ADDON_IDS } from '../../addons'; import { AddonsService } from '../addons/addons.service'; @@ -16,7 +16,7 @@ export class GoogleRouteMcp { async preview({ url }: { url: string }) { return answeringRefusals(async () => ok(await this.routes.preview(url))); } @Tool({ name: 'import_google_maps_route', description: 'Append reviewed Google Maps stops to an existing trip day in supplied order. Creates places and visits atomically. Requires place and day editing permissions. Existing visits remain. TREK calculates the road geometry; no Google route geometry is preserved.', - inputSchema: { tripId: z.number().int().positive(), ...googleRouteImportSchema.shape, stops: z.array(z.strictObject(googleRouteStopSchema.shape)).min(2).max(30) }, + inputSchema: { tripId: idSchema, ...googleRouteImportSchema.shape, stops: z.array(z.strictObject(googleRouteStopSchema.shape)).min(2).max(30) }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, // What the tool writes, not where the link came from: this inserts up to // thirty places and their day assignments, which is exactly what create_place diff --git a/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts index cb97a2931..b1a995fc6 100644 --- a/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-hazards.mcp.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { noAccess } from '../../mcp/tools/_shared'; import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; @@ -13,7 +13,7 @@ export class RoadtripHazardsMcp { @Tool({ name: 'get_roadtrip_hazards', description: 'Read current DWD and GDACS hazard notices, geometry, source timestamps and feed availability. These are current notices, not forecasts for the trip dates or confirmed road closures. A point means no affected-area polygon is available. Coverage can be incomplete. This does not change routes. The shared roadtrip_show_hazards preference controls map display.', - inputSchema: { tripId: z.number().int().positive() }, + inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP), }) async read({ tripId }: { tripId: number }, ctx: McpContext) { diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts index 680750d30..f25797f99 100644 --- a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts @@ -9,6 +9,7 @@ import { roadtripCorridorRequestSchema, type RoadtripPlanRequest, type RoadtripCorridorRequest, + idSchema, } from '@trek/shared'; import { corridorTiles, @@ -21,7 +22,6 @@ import { } from '@trek/shared/roadtrip'; import { answeringRefusals } from './roadtrip-mcp.helpers'; -import { z } from 'zod'; const when = addonGate(ADDON_IDS.ROADTRIP); @@ -37,7 +37,7 @@ export class RoadtripPlanningMcp { name: 'get_roadtrip_context', description: 'Read the saved roadtrip days, visits, pinned times, end times (when the drive leaves a visit), stays, stop types, fill levels, travel modes, via points, followed tracks, manual day endings and the carrier bookings (flight, train, ferry, cruise, bus) that seam the drive at their terminals, plus the hire cars whose pick-up and return desks stand on it. stays lists every booked stay with its check-in and check-out day, its place and the earliest linked reservation (reservation_id). Includes the shared driving preferences for this trip. No routing request and no browser needed. Coordinates missing from a visit prevent it from being routed. Use calculate_roadtrip to get the derived day layout. Edit visits with the existing place and assignment tools; change their saved order with reorder_day_assignments. Settings, visits and manual boundaries all belong to the shared trip.', - inputSchema: { tripId: z.number().int().positive() }, + inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when, diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts index 196ade17e..628657cc4 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts @@ -13,9 +13,8 @@ import { AddonsService } from '../addons/addons.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { RoadtripPreferencesService } from './roadtrip-preferences.service'; import { answeringRefusals } from './roadtrip-mcp.helpers'; -import { roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared'; +import { idSchema, roadtripPreferencesUpdateSchema, type RoadtripPreferences } from '@trek/shared'; -import { z } from 'zod'; import { TripAccessService } from '../trip-membership/trip-access.service'; const when = addonGate(ADDON_IDS.ROADTRIP); @@ -33,7 +32,7 @@ export class RoadtripPreferencesMcp { name: 'get_roadtrip_settings', description: 'Read the shared driving preferences for one trip, including daily start and end times, day-ending behavior, vehicle specifications, range, fill percentage, driving limits, avoided road classes and route display options. Every member of this trip sees the same preferences. Missing numbers mean no limit, missing daily times disable automatic scheduling, missing vehicle means unspecified, and missing end mode means route. Missing roadtrip_hotel_bookends means off. Range and consumption always use kilometres, litres and kWh. Instance URLs and credentials are not exposed.', - inputSchema: { tripId: z.number().int().positive() }, + inputSchema: { tripId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when, @@ -47,7 +46,7 @@ export class RoadtripPreferencesMcp { name: 'update_roadtrip_settings', description: 'Change the shared driving preferences for this trip. Changes apply to all its travellers and require permission to edit days. Read get_roadtrip_settings first for relative changes. Daily times use HH:mm; clear either with an empty string to disable automatic daily scheduling. Fixed visit times retain priority. Numeric zero clears a limit. Vehicle specifications take precedence over manual range when complete; clear the relevant specifications to use a manual range. Avoid classes are a comma-separated selection of toll,motorway,ferry. Unspecified fields remain unchanged. End mode route pauses at the cutoff; stop ends at the last reachable visit. Set roadtrip_hotel_bookends true to start and end each day at the stay: a day after a booked night starts where it was slept, a day before one ends there. False, like missing, drives each day from its first stored stop, as before. Per-visit end_day and dragged boundaries are edited with set_assignment_end_day and set_day_boundary. This never changes instance routing URLs or credentials.', - inputSchema: { tripId: z.number().int().positive(), settings: roadtripPreferencesUpdateSchema }, + inputSchema: { tripId: idSchema, settings: roadtripPreferencesUpdateSchema }, annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when, diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index f6a64c5c1..cea8fc41d 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -6,7 +6,7 @@ import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { ADDON_IDS } from '../../addons'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; -import { roadtripViaUpdateRequestSchema, type RoadtripViaUpdateRequest } from '@trek/shared'; +import { idSchema, roadtripViaUpdateRequestSchema, type RoadtripViaUpdateRequest } from '@trek/shared'; import { TripAccessService } from '../trip-membership/trip-access.service'; /** @@ -47,8 +47,8 @@ export class RoadtripMcp { name: 'list_route_vias', description: 'List the points a day\'s drive is routed through without stopping at them. These bend the route (a scenic road, a pass, avoiding a motorway) and are not stops on the itinerary.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive().optional().describe('Omit to list the vias of every day of the trip'), + tripId: idSchema, + dayId: idSchema.optional().describe('Omit to list the vias of every day of the trip'), }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, @@ -67,8 +67,8 @@ export class RoadtripMcp { name: 'add_route_via', description: `Make a day's drive pass through a point without stopping there: use it to send the route over a particular road or away from one. For somewhere the traveller actually stops, add a place and assign it to the day instead. ${BOOKEND_VIA_NOTE}`, inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, after_order_index: z.number().int().min(0).describe('Which stop of the day the via follows, counting from 0'), lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), @@ -93,8 +93,8 @@ export class RoadtripMcp { name: 'add_route_vias', description: `Lay a whole chain of via points on one day at once, so the drive follows a particular road for a stretch rather than being nudged at a single point. Use this when the shape comes from a line (a recorded track, a signed scenic route) and add_route_via when it is one detour. Pass replace_legs to clear the vias on those legs first; leave it out to add to what is already there, which is what leaves hand-placed detours on other legs alone. ${BOOKEND_VIA_NOTE}`, inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, vias: z.array(z.strictObject({ after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'), lat: z.number().min(-90).max(90), @@ -103,7 +103,7 @@ export class RoadtripMcp { replace_legs: z.array(z.number().int().min(0)).max(100).optional() .describe('Legs to clear before inserting, by the index of the stop they follow'), track: z.strictObject({ - place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'), + place_id: idSchema.describe('The imported track this chain was fitted to, as its place id'), stray_km: z.number().min(0).max(40_000).nullable().optional() .describe('How far the fitted route still runs from the track at its worst point'), }).nullable().optional() @@ -143,13 +143,13 @@ export class RoadtripMcp { name: 'reanchor_route_vias', description: 'Re-pin a day\'s via points after its stops changed. A via records which stop it follows by position, so adding, removing or reordering a stop leaves every later via pointing at the wrong leg and the drive silently reverts to the road it was steered away from. Send the corrected positions for the whole day at once; ids left out keep the position they have.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, vias: z.array(z.strictObject({ - id: z.number().int().positive(), + id: idSchema, after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'), })).max(500), - remove: z.array(z.number().int().positive()).max(500).optional() + remove: z.array(idSchema).max(500).optional() .describe('Vias whose leg no longer exists at all — deleting the last stop of a day leaves the leg into it with nothing to sit on'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -172,9 +172,9 @@ export class RoadtripMcp { name: 'remove_route_via', description: 'Remove a via point, letting the drive take the direct route again.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive(), - viaId: z.number().int().positive(), + tripId: idSchema, + dayId: idSchema, + viaId: idSchema, }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, @@ -192,7 +192,7 @@ export class RoadtripMcp { @Tool({ name: 'update_route_via', description: 'Move an existing via point and optionally attach it to another outgoing leg of the same day. This is the same change as dragging a route handle. A via bends the route and does not add a stop or stay.', - inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), viaId: z.number().int().positive(), ...roadtripViaUpdateRequestSchema.shape }, + inputSchema: { tripId: idSchema, dayId: idSchema, viaId: idSchema, ...roadtripViaUpdateRequestSchema.shape }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, when: roadtripAddonOn, }) async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { diff --git a/server/src/nest/school-holidays/school-holidays.mcp.ts b/server/src/nest/school-holidays/school-holidays.mcp.ts index 49b12d63b..869b9250b 100644 --- a/server/src/nest/school-holidays/school-holidays.mcp.ts +++ b/server/src/nest/school-holidays/school-holidays.mcp.ts @@ -76,7 +76,7 @@ export class SchoolHolidaysMcp { @Tool({ name: 'delete_manual_school_holiday_region', description: 'Admin only. Delete a global manual school region and its periods using the current revision. Regions selected by a vacation calendar cannot be deleted.', - inputSchema: { regionId: idSchema, revision: z.number().int().positive() }, + inputSchema: { regionId: idSchema, revision: idSchema }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' }, }) async deleteRegion({ regionId, revision }: { regionId: number; revision: number }, ctx: McpContext) { diff --git a/server/src/nest/share/share.mcp.ts b/server/src/nest/share/share.mcp.ts index 234113d6b..ec08cf5d9 100644 --- a/server/src/nest/share/share.mcp.ts +++ b/server/src/nest/share/share.mcp.ts @@ -5,6 +5,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { canShareTrips } from '../../mcp/scopes'; import { ShareService } from './share.service'; @@ -28,7 +29,7 @@ export class ShareMcp { name: 'get_share_link', description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: (ctx) => canShareTrips(ctx.scopes), @@ -48,7 +49,7 @@ export class ShareMcp { name: 'create_share_link', description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, share_map: z.boolean().optional().default(true).describe('Share the map and places'), share_bookings: z.boolean().optional().default(true).describe('Share reservations'), share_packing: z.boolean().optional().default(false).describe('Share packing list'), @@ -81,7 +82,7 @@ export class ShareMcp { name: 'delete_share_link', description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: (ctx) => canShareTrips(ctx.scopes), diff --git a/server/src/nest/tags/tags.mcp.ts b/server/src/nest/tags/tags.mcp.ts index 2ef64ebd7..94f582c9c 100644 --- a/server/src/nest/tags/tags.mcp.ts +++ b/server/src/nest/tags/tags.mcp.ts @@ -5,6 +5,7 @@ import { errorResult, ok, } from '../../nest-mcp'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { TagsService } from './tags.service'; /** @@ -48,7 +49,7 @@ export class TagsMcp { name: 'update_tag', description: 'Update the name or color of an existing tag.', inputSchema: { - tagId: z.number().int().positive(), + tagId: idSchema, name: z.string().optional(), color: z.string().optional(), }, @@ -66,7 +67,7 @@ export class TagsMcp { name: 'delete_tag', description: 'Delete a tag (removes it from all places it was attached to).', inputSchema: { - tagId: z.number().int().positive(), + tagId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'places', mode: 'write' }, diff --git a/server/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index 77f3b1f23..0529597e0 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -6,6 +6,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { ADDON_IDS } from '../../addons'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { TodoService } from './todo.service'; @@ -41,7 +42,7 @@ export class TodoMcp { name: 'list_todos', description: 'List all to-do items for a trip, ordered by position.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, when: packingAddonOn, @@ -57,12 +58,12 @@ export class TodoMcp { name: 'create_todo', description: 'Create a new to-do item for a trip.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, 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'), + assigned_user_id: idSchema.optional().describe('User ID to assign this task to'), priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=P1 (highest), 2=P2, 3=P3 (lowest)'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -86,13 +87,13 @@ export class TodoMcp { name: '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(), + tripId: idSchema, + itemId: idSchema, 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'), + assigned_user_id: idSchema.nullable().optional().describe('Set to null to unassign'), priority: z.number().int().min(0).max(3).nullable().optional().describe('Priority: 0=none, 1=P1 (highest), 2=P2, 3=P3 (lowest); null clears it'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -123,8 +124,8 @@ export class TodoMcp { name: 'toggle_todo', description: 'Mark a to-do item as checked (done) or unchecked.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, checked: z.boolean().describe('True to mark done, false to uncheck'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -144,8 +145,8 @@ export class TodoMcp { name: 'delete_todo', description: 'Delete a to-do item.', inputSchema: { - tripId: z.number().int().positive(), - itemId: z.number().int().positive(), + tripId: idSchema, + itemId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: packingAddonOn, @@ -164,8 +165,8 @@ export class TodoMcp { name: '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'), + tripId: idSchema, + orderedIds: z.array(idSchema).min(1).describe('All item IDs in the desired order'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: packingAddonOn, @@ -182,7 +183,7 @@ export class TodoMcp { name: 'get_todo_category_assignees', description: 'Get the default assignees configured per to-do category for a trip.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, when: packingAddonOn, @@ -198,9 +199,9 @@ export class TodoMcp { name: '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(), + tripId: idSchema, 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'), + userIds: z.array(idSchema).describe('User IDs to assign as defaults for this category'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: packingAddonOn, diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index c2ca7f568..8a441df1b 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -5,6 +5,7 @@ import { } from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; +import { idSchema } from '@trek/shared'; import { buildTransitReservationParts, cleanTransitItineraryNames, @@ -166,8 +167,8 @@ export class TransitMcp { description: 'Add one itinerary returned by search_transit_routes to a trip day as a first-class automated public-transit journey.', inputSchema: { - tripId: z.number().int().positive(), - dayId: z.number().int().positive().describe('Trip day on which the journey departs'), + tripId: idSchema, + dayId: idSchema.describe('Trip day on which the journey departs'), from: transitPlaceInput, to: transitPlaceInput, itinerary: transitItinerarySchema, diff --git a/server/src/nest/trip-invite/trip-invite.mcp.ts b/server/src/nest/trip-invite/trip-invite.mcp.ts index 77246a679..2a52ca334 100644 --- a/server/src/nest/trip-invite/trip-invite.mcp.ts +++ b/server/src/nest/trip-invite/trip-invite.mcp.ts @@ -3,14 +3,13 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, ok, } from '../../nest-mcp'; -import { z } from 'zod'; import { getAppUrl } from '../../app-config'; import { AuditService } from '../audit/audit.service'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { canShareTrips, canWrite } from '../../mcp/scopes'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; -import { tripInviteLinkCreateRequestSchema } from '@trek/shared'; +import { idSchema, tripInviteLinkCreateRequestSchema } from '@trek/shared'; import type { TripInviteLinkCreateRequest } from '@trek/shared'; import { TripInviteService, type TripInviteInfo } from './trip-invite.service'; @@ -53,7 +52,7 @@ export class TripInviteMcp { name: 'get_trip_invite_link', description: 'Get the trip\'s current invite link, the one that adds whoever opens it to the trip as a member. Returns null when the trip has none. This is not the read-only public view link: get_share_link is that one.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'), @@ -69,7 +68,7 @@ export class TripInviteMcp { name: 'create_trip_invite_link', description: 'Create the trip\'s invite link, or rotate it: a trip has exactly one, so calling this again issues a fresh token and the previous link stops working immediately. Anyone with a TREK account who opens the link and signs in becomes a member of the trip, which makes it a different and far stronger thing than create_share_link, whose link only shows a read-only public view. Use add_trip_member instead when the person already has an account and is known by name or email.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, expires_in_days: tripInviteLinkCreateRequestSchema.shape.expires_in_days .describe('Days until the link stops working; omit, null or 0 leaves it valid until it is rotated or deleted'), }, @@ -107,7 +106,7 @@ export class TripInviteMcp { name: 'delete_trip_invite_link', description: 'Revoke the trip\'s invite link. The URL stops working at once, so nobody else can join through it. Members who already joined stay on the trip: remove_trip_member is what takes somebody off it.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: (ctx) => canShareTrips(ctx.scopes) && canWrite(ctx.scopes, 'trips'), diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index df68262ad..0d95cda61 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -14,7 +14,7 @@ import { TripReadModelService } from '../trip-read-model/trip-read-model.service import { ADDON_IDS } from '../../addons'; import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { canRead, canReadTrips, canDeleteTrips } from '../../mcp/scopes'; -import { MAX_TRIP_DAYS } from '@trek/shared'; +import { idSchema, MAX_TRIP_DAYS } from '@trek/shared'; import { TripsService, NotFoundError, ValidationError } from './trips.service'; import { TodoService } from '../todo/todo.service'; import { CollabService } from '../collab/collab.service'; @@ -129,7 +129,7 @@ export class TripsMcp { name: 'update_trip', description: 'Update an existing trip\'s details. Shortening a dated trip deletes its last days by position, with their planned places, notes and any stay that checks in or out on them; day plans move with the dates, so a later start with the same end also takes the last days. When a change removed days, the result lists them in removed_days (id, day_number and date as they stood before; reason overflow for a day past the new range, spare for an empty one).', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, title: z.string().min(1).max(200).optional(), description: z.string().max(2000).nullable().optional().describe('Trip description; null removes it'), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), @@ -217,7 +217,7 @@ export class TripsMcp { name: 'delete_trip', description: 'Delete a trip. Only the trip owner can delete it.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: (ctx) => canDeleteTrips(ctx.scopes), @@ -257,7 +257,7 @@ export class TripsMcp { name: 'get_trip_summary', description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, }) @@ -325,7 +325,7 @@ export class TripsMcp { name: 'list_trip_members', description: 'List all members of a trip (owner + collaborators).', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: (ctx) => canReadTrips(ctx.scopes), @@ -342,7 +342,7 @@ export class TripsMcp { name: 'add_trip_member', description: 'Add a user to a trip by their username or email address. Needs the member_manage permission, which by default only the trip owner holds. Use create_trip_guest instead for a companion who has no TREK account.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, identifier: z.string().min(1).describe('Username or email of the user to add'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -370,8 +370,8 @@ export class TripsMcp { name: 'remove_trip_member', description: 'Remove somebody else from a trip. Needs the member_manage permission, which by default only the trip owner holds. When the user means themselves, prefer leave_trip: it says so plainly and needs no permission.', inputSchema: { - tripId: z.number().int().positive(), - memberId: z.number().int().positive().describe('User ID of the member to remove'), + tripId: idSchema, + memberId: idSchema.describe('User ID of the member to remove'), }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'trips', mode: 'write' }, @@ -391,7 +391,7 @@ export class TripsMcp { name: 'leave_trip', description: 'Leave a trip you were invited to, giving up your own access to it. Prefer this over remove_trip_member whenever the user means themselves. The owner cannot leave their own trip.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'trips', mode: 'write' }, @@ -423,7 +423,7 @@ export class TripsMcp { name: 'create_trip_guest', description: 'Add a travelling companion who has no TREK account to a trip. Use this when the person cannot be found by username or email. A guest can be assigned to budget splits, packing items, to-dos and day participants like any member, but never signs in and is never emailed. Only the trip owner can do this.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, name: z.string().min(1).max(50).describe('Display name of the guest, e.g. "Anna"'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -449,8 +449,8 @@ export class TripsMcp { name: 'rename_trip_guest', description: 'Rename a guest on a trip. Only the trip owner can do this.', inputSchema: { - tripId: z.number().int().positive(), - guestId: z.number().int().positive().describe('User ID of the guest, from list_trip_members'), + tripId: idSchema, + guestId: idSchema.describe('User ID of the guest, from list_trip_members'), name: z.string().min(1).max(50).describe('New display name'), }, annotations: TOOL_ANNOTATIONS_WRITE, @@ -475,8 +475,8 @@ export class TripsMcp { name: 'delete_trip_guest', description: 'Remove a guest from a trip. This deletes the guest outright (they exist only for this trip) and re-splits any expenses they were part of. Only the trip owner can do this.', inputSchema: { - tripId: z.number().int().positive(), - guestId: z.number().int().positive().describe('User ID of the guest, from list_trip_members'), + tripId: idSchema, + guestId: idSchema.describe('User ID of the guest, from list_trip_members'), }, annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'trips', mode: 'write' }, @@ -496,7 +496,7 @@ export class TripsMcp { name: 'copy_trip', description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items and to-dos are reset to unchecked. Returns the new trip.', inputSchema: { - tripId: z.number().int().positive().describe('Source trip ID to duplicate'), + tripId: idSchema.describe('Source trip ID to duplicate'), title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -517,7 +517,7 @@ export class TripsMcp { name: 'export_trip_ics', description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.', inputSchema: { - tripId: z.number().int().positive(), + tripId: idSchema, }, annotations: TOOL_ANNOTATIONS_READONLY, access: (ctx) => canReadTrips(ctx.scopes), diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts index 266e677f2..e5e01c28b 100644 --- a/server/src/nest/vacay/vacay.mcp.ts +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -11,6 +11,7 @@ import { vacayAddHolidayCalendarRequestSchema, vacayToggleEntryRequestSchema, vacayUpdatePlanRequestSchema, + idSchema, } from '@trek/shared'; import type { VacayUpdatePlanRequest } from '@trek/shared'; import { InjectRepository } from '@mikro-orm/nestjs'; @@ -176,7 +177,7 @@ export class VacayMcp { name: 'send_vacay_invite', description: 'Invite a user to join the vacation plan by their user ID.', inputSchema: { - targetUserId: z.number().int().positive(), + targetUserId: idSchema, }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: vacayAddonOn, @@ -194,7 +195,7 @@ export class VacayMcp { name: 'accept_vacay_invite', description: "Accept a pending invitation to join another user's vacation plan.", inputSchema: { - planId: z.number().int().positive(), + planId: idSchema, }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: vacayAddonOn, @@ -209,7 +210,7 @@ export class VacayMcp { name: 'decline_vacay_invite', description: 'Decline a pending vacation plan invitation.', inputSchema: { - planId: z.number().int().positive(), + planId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, when: vacayAddonOn, @@ -224,7 +225,7 @@ export class VacayMcp { name: 'cancel_vacay_invite', description: 'Cancel an outgoing invitation (owner cancels invite they sent).', inputSchema: { - targetUserId: z.number().int().positive(), + targetUserId: idSchema, }, annotations: TOOL_ANNOTATIONS_WRITE, when: vacayAddonOn, @@ -318,7 +319,7 @@ export class VacayMcp { date: z.string().describe('ISO date YYYY-MM-DD'), fraction: vacayToggleEntryRequestSchema.shape.fraction.describe('0.5 for a half day, 1 (the default) for a full day'), kind: vacayToggleEntryRequestSchema.shape.kind.describe("'comp' for a flex/comp day, which does not draw on the entitlement; 'vacation' is the default"), - targetUserId: z.number().int().positive().optional().describe('Log the day for another member of the shared plan instead of the caller'), + targetUserId: idSchema.optional().describe('Log the day for another member of the shared plan instead of the caller'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: vacayAddonOn, @@ -423,7 +424,7 @@ export class VacayMcp { name: 'update_holiday_calendar', description: 'Update label or color for a holiday calendar.', inputSchema: { - calendarId: z.number().int().positive(), + calendarId: idSchema, label: z.string().nullable().optional(), color: z.string().optional(), }, @@ -445,7 +446,7 @@ export class VacayMcp { name: 'delete_holiday_calendar', description: 'Remove a holiday calendar from the vacation plan.', inputSchema: { - calendarId: z.number().int().positive(), + calendarId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: vacayAddonOn, @@ -550,7 +551,7 @@ export class VacayMcp { name: 'share_vacay_calendar', description: "Share the current user's vacation calendar with another user (view only, no merge).", inputSchema: { - targetUserId: z.number().int().positive(), + targetUserId: idSchema, }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, when: vacayAddonOn, @@ -567,7 +568,7 @@ export class VacayMcp { name: 'unshare_vacay_calendar', description: 'Remove a read-only calendar share the current user is part of (revoke as owner, or remove a calendar shared with them).', inputSchema: { - shareId: z.number().int().positive(), + shareId: idSchema, }, annotations: TOOL_ANNOTATIONS_DELETE, when: vacayAddonOn, diff --git a/server/tests/unit/mcp/tool-input-schemas.test.ts b/server/tests/unit/mcp/tool-input-schemas.test.ts new file mode 100644 index 000000000..e4d0b4130 --- /dev/null +++ b/server/tests/unit/mcp/tool-input-schemas.test.ts @@ -0,0 +1,85 @@ +/** + * The JSON Schema every registered MCP tool advertises in tools/list, built the + * way the registry and the SDK build it (a shape is registered strict, then + * converted by zod's toJSONSchema). It walks every *.mcp.ts under src/nest, so a + * tool needs no harness wiring to be covered. + * + * What it holds: an input schema is self-contained (no $ref or $defs, which + * several MCP clients do not resolve), and every id field is the positive + * integer of @trek/shared's idSchema, the shared primitive the tools derive + * their ids from. + */ +import type { ToolOptions } from '../../../src/nest-mcp'; +import { getEntry, isMcpController, type ClassRef } from '../../../src/nest-mcp/metadata'; +import { idSchema } from '@trek/shared'; + +import { readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { beforeAll, describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +const NEST_ROOT = join(__dirname, '../../../src/nest'); + +function mcpFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + if (statSync(full).isDirectory()) return mcpFiles(full); + return name.endsWith('.mcp.ts') ? [full] : []; + }); +} + +function methodNames(ctor: ClassRef): string[] { + const names = new Set(); + let proto: object | null = (ctor as unknown as { prototype: object }).prototype; + while (proto && proto !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(proto)) if (name !== 'constructor') names.add(name); + proto = Object.getPrototypeOf(proto); + } + return [...names]; +} + +async function toolSchemas(): Promise>> { + const out = new Map>(); + for (const file of mcpFiles(NEST_ROOT).sort()) { + const mod = (await import(file)) as Record; + for (const exported of Object.values(mod)) { + if (!isMcpController(exported)) continue; + for (const name of methodNames(exported)) { + const entry = getEntry(exported, name); + if (entry?.kind !== 'tool') continue; + const input = (entry.options as ToolOptions).inputSchema; + if (input === undefined) continue; + const schema = '_zod' in input ? input : z.strictObject(input); + const json = z.toJSONSchema(schema as z.ZodType, { target: 'draft-7', io: 'input' }) as Record; + out.set(`${relative(NEST_ROOT, file)}#${entry.options.name}`, json); + } + } + } + return out; +} + +describe('MCP tool input schemas', () => { + let schemas: Map>; + // Importing every MCP module pulls in most of the server once. + beforeAll(async () => { + schemas = await toolSchemas(); + }, 180_000); + + it('MCP-SCHEMA-001: every tool advertises a self-contained input schema', () => { + expect(schemas.size).toBeGreaterThan(150); + const withRefs = [...schemas] + .filter(([, json]) => /"\$ref"|"\$defs"|"definitions"/.test(JSON.stringify(json))) + .map(([key]) => key); + expect(withRefs).toEqual([]); + }); + + it('MCP-SCHEMA-002: a shared id stays the positive integer it always was in tools/list', () => { + const id = z.toJSONSchema(idSchema, { target: 'draft-7', io: 'input' }) as Record; + expect(id).toMatchObject({ type: 'integer', exclusiveMinimum: 0 }); + const tripIds = [...schemas.values()] + .map((json) => (json.properties as Record> | undefined)?.tripId) + .filter((p): p is Record => !!p && p.type === 'integer'); + expect(tripIds.length).toBeGreaterThan(100); + for (const prop of tripIds) expect(prop).toMatchObject({ type: 'integer', exclusiveMinimum: 0 }); + }); +});