From fa8a43b16e8b3c6a87ca751b8283c27b632d2b84 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 00:03:19 +0000 Subject: [PATCH] feat(assistant): expenses, shares, wind, journey tracks and Dawarich checks as MCP tools - list_budget_items lists a trip's expenses with their ids, payers and splits, page by page so no page is cut down on its way to a model; get_budget_per_person answers who paid and owes what. Both in a new budget-items.mcp.ts, checked like their REST twins. - get_wind_forecast reads the hourly wind, gusts and direction of a day. - get_journey_tracks lists a journey's GPX tracks; the line for drawing rides under a key the provider never receives. - The Dawarich wishlist scan and the countries the recordings suggest become tools, with their confirming writes, in DawarichAtlasMcp in the Atlas group; the shared time window check refuses alike on REST and MCP, and the model never sees when the user was last seen in a city. - The journal's map, tracks and recorded days load with the journey group instead of riding in the core of every step. - The prompt points at list_budget_items for expense ids (the trip summary never carried them) and names the shares and the wind tools; each new read has its own live step. --- client/scripts/i18n-keys.mjs | 7 + .../Assistant/assistantModel.test.ts | 20 + .../components/Assistant/assistantModel.ts | 6 + server/src/nest/assistant/assistant-redact.ts | 18 + .../nest/assistant/assistant-tool-offer.ts | 20 +- server/src/nest/assistant/assistant.prompt.ts | 3 +- server/src/nest/budget/budget-items.mcp.ts | 149 +++++++ server/src/nest/budget/budget.module.ts | 6 +- .../nest/integrations/dawarich-atlas.mcp.ts | 130 ++++++ .../dawarich-suggestions.service.ts | 25 +- .../nest/integrations/dawarich.controller.ts | 24 +- .../src/nest/integrations/dawarich.helpers.ts | 30 +- server/src/nest/integrations/dawarich.mcp.ts | 27 +- .../src/nest/integrations/dawarich.module.ts | 2 + server/src/nest/journey/journey-tracks.mcp.ts | 82 ++++ server/src/nest/journey/journey.module.ts | 3 +- server/src/nest/weather/weather.mcp.ts | 42 +- server/tests/e2e/budget.e2e.test.ts | 77 ++++ server/tests/e2e/dawarich.e2e.test.ts | 136 ++++++- server/tests/e2e/journey.e2e.test.ts | 58 +++ server/tests/e2e/weather.e2e.test.ts | 61 +++ .../nest/assistant/assistant-small.test.ts | 9 + .../assistant/assistant-tools.service.test.ts | 32 ++ .../tests/unit/nest/budget-items.mcp.test.ts | 379 +++++++++++++++++ .../unit/nest/dawarich-atlas.mcp.test.ts | 382 ++++++++++++++++++ .../nest/dawarich-suggestions.service.test.ts | 37 +- .../tests/unit/nest/dawarich.helpers.test.ts | 23 ++ .../unit/nest/journey-tracks.mcp.test.ts | 196 +++++++++ .../unit/nest/weather/weather.mcp.test.ts | 146 +++++++ shared/src/budget/budget.schema.spec.ts | 14 + shared/src/budget/budget.schema.ts | 27 ++ shared/src/dawarich/dawarich.schema.spec.ts | 17 +- shared/src/dawarich/dawarich.schema.ts | 24 ++ shared/src/i18n/ar/assistant.ts | 9 + shared/src/i18n/az/assistant.ts | 9 + shared/src/i18n/br/assistant.ts | 9 + shared/src/i18n/ca/assistant.ts | 9 + shared/src/i18n/cs/assistant.ts | 9 + shared/src/i18n/de/assistant.ts | 9 + shared/src/i18n/en/assistant.ts | 9 + shared/src/i18n/es/assistant.ts | 9 + shared/src/i18n/et/assistant.ts | 9 + shared/src/i18n/fr/assistant.ts | 9 + shared/src/i18n/gr/assistant.ts | 9 + shared/src/i18n/hu/assistant.ts | 9 + shared/src/i18n/id/assistant.ts | 9 + shared/src/i18n/it/assistant.ts | 9 + shared/src/i18n/ja/assistant.ts | 9 + shared/src/i18n/ko/assistant.ts | 9 + shared/src/i18n/nl/assistant.ts | 9 + shared/src/i18n/pl/assistant.ts | 9 + shared/src/i18n/ru/assistant.ts | 9 + shared/src/i18n/sk/assistant.ts | 9 + shared/src/i18n/sv/assistant.ts | 9 + shared/src/i18n/th/assistant.ts | 9 + shared/src/i18n/tr/assistant.ts | 9 + shared/src/i18n/uk/assistant.ts | 9 + shared/src/i18n/vi/assistant.ts | 9 + shared/src/i18n/zh-TW/assistant.ts | 9 + shared/src/i18n/zh/assistant.ts | 9 + wiki/Dawarich.md | 5 + wiki/MCP-Addon-Tools.md | 6 + wiki/MCP-Tools-and-Resources.md | 3 + 63 files changed, 2418 insertions(+), 51 deletions(-) create mode 100644 server/src/nest/budget/budget-items.mcp.ts create mode 100644 server/src/nest/integrations/dawarich-atlas.mcp.ts create mode 100644 server/src/nest/journey/journey-tracks.mcp.ts create mode 100644 server/tests/unit/nest/budget-items.mcp.test.ts create mode 100644 server/tests/unit/nest/dawarich-atlas.mcp.test.ts create mode 100644 server/tests/unit/nest/journey-tracks.mcp.test.ts create mode 100644 server/tests/unit/nest/weather/weather.mcp.test.ts diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs index b991a9c00..57aab93fe 100644 --- a/client/scripts/i18n-keys.mjs +++ b/client/scripts/i18n-keys.mjs @@ -139,6 +139,13 @@ export const DYNAMIC_ALLOWED = [ 'memories.* key in en', })) ), + { + file: 'components/Assistant/AssistantChangeCard.tsx', + template: 'assistant.tool.${tool}', + because: + 'tool is the MCP tool a change card is for; verbLabel shows assistant.tool. only where en names ' + + 'that tool, and the generic add, change or remove verb for every other tool', + }, ]; const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/; diff --git a/client/src/components/Assistant/assistantModel.test.ts b/client/src/components/Assistant/assistantModel.test.ts index 6ba6e8619..11836b549 100644 --- a/client/src/components/Assistant/assistantModel.test.ts +++ b/client/src/components/Assistant/assistantModel.test.ts @@ -130,6 +130,26 @@ describe('toolStepKey', () => { expect(toolStepKey('constructor')).toBe('assistant.step.working'); }); + it('FE-AI-MODEL-011: names the expense, share, wind, track and Dawarich reads by what they look at', () => { + expect( + [ + 'list_budget_items', + 'get_budget_per_person', + 'get_wind_forecast', + 'get_journey_tracks', + 'scan_bucket_list_visits', + 'suggest_atlas_countries', + ].map(toolStepKey) + ).toEqual([ + 'assistant.step.expenses', + 'assistant.step.shares', + 'assistant.step.wind', + 'assistant.step.tracks', + 'assistant.step.bucketScan', + 'assistant.step.atlasCountries', + ]); + }); + it('FE-AI-MODEL-010: a running step that names a place or a query says it, and falls back to its name', () => { expect(liveStepText('search_nearby_places', 'Trastevere')).toEqual({ key: 'assistant.step.nearbyAt', diff --git a/client/src/components/Assistant/assistantModel.ts b/client/src/components/Assistant/assistantModel.ts index 064620e4e..aec0f687b 100644 --- a/client/src/components/Assistant/assistantModel.ts +++ b/client/src/components/Assistant/assistantModel.ts @@ -167,6 +167,12 @@ const STEPS: Partial> = { get_weather: { stepKey: 'assistant.step.weather', live: { liveKey: 'assistant.step.weatherAt', param: 'place' } }, list_todos: { stepKey: 'assistant.step.todos' }, get_settlement_summary: { stepKey: 'assistant.step.costs' }, + list_budget_items: { stepKey: 'assistant.step.expenses' }, + get_budget_per_person: { stepKey: 'assistant.step.shares' }, + get_wind_forecast: { stepKey: 'assistant.step.wind' }, + get_journey_tracks: { stepKey: 'assistant.step.tracks' }, + scan_bucket_list_visits: { stepKey: 'assistant.step.bucketScan' }, + suggest_atlas_countries: { stepKey: 'assistant.step.atlasCountries' }, propose_hike: { stepKey: 'assistant.step.planningHike', live: { liveKey: 'assistant.step.planningHikeTo', param: 'place' }, diff --git a/server/src/nest/assistant/assistant-redact.ts b/server/src/nest/assistant/assistant-redact.ts index 9cb08b80e..7ddb64b64 100644 --- a/server/src/nest/assistant/assistant-redact.ts +++ b/server/src/nest/assistant/assistant-redact.ts @@ -108,8 +108,26 @@ function projectTripSummary(summary: Json): Json { }; } +/** + * `suggest_atlas_countries` as the model gets it: each country with its + * cities and the minutes spent in them, without when the user was last seen + * in each city. The model offers countries to mark; a time per city would + * hand the provider a dated trail of the user's whereabouts for nothing it + * plans with. + */ +function projectAtlasSuggestions(value: Json): Json { + if (!isRecord(value) || !Array.isArray(value.countries)) return value; + return { + ...value, + countries: value.countries.map((country) => + isRecord(country) ? { ...country, cities: pickAll(country.cities, ['name', 'minutes']) } : country, + ), + }; +} + const PROJECTIONS: Readonly Json>> = { get_trip_summary: projectTripSummary, + suggest_atlas_countries: projectAtlasSuggestions, }; function shrink(value: Json, cap: number, cut: { done: boolean }): Json { diff --git a/server/src/nest/assistant/assistant-tool-offer.ts b/server/src/nest/assistant/assistant-tool-offer.ts index c59f3fc8e..97b7e606b 100644 --- a/server/src/nest/assistant/assistant-tool-offer.ts +++ b/server/src/nest/assistant/assistant-tool-offer.ts @@ -143,14 +143,28 @@ export const ASSISTANT_TOOL_GROUPS: Readonly in every later conversation. Never say you cannot remember things. -- Existing expenses can be changed with update_budget_item (their ids are in get_trip_summary); never add a second entry to correct one. +- Expenses and their ids come from list_budget_items. Change an existing one with update_budget_item; never add a second entry to correct one. For what each person's share comes to, use get_budget_per_person; for who pays whom, get_settlement_summary. +- For the wind on a day (a paddle, a sail, a ridge walk), use get_wind_forecast: it has the gusts and the direction hour by hour. - If the request is unclear, ask one short question instead of guessing.`; /** The standing instructions, telling the model which language to answer in. */ diff --git a/server/src/nest/budget/budget-items.mcp.ts b/server/src/nest/budget/budget-items.mcp.ts new file mode 100644 index 000000000..9e7750871 --- /dev/null +++ b/server/src/nest/budget/budget-items.mcp.ts @@ -0,0 +1,149 @@ +import { ADDON_IDS } from '../../addons'; +import { noAccess } from '../../mcp/tools/_shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; +import type { BudgetItem } from '../../types'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { BudgetService } from './budget.service'; +import { BUDGET_ITEMS_PAGE_DEFAULT, budgetItemsPageSchema, idSchema, type BudgetItemsPage } from '@trek/shared'; + +/** The whole budget surface rides the budget addon, as in BudgetMcp. */ +const budgetAddonOn = addonGate(ADDON_IDS.BUDGET); + +/** + * How long the expenses of one page may run as JSON. The assistant cuts a tool + * answer at 40,000 characters, and a page cut there would leave its last rows + * unread while next_offset skips past them. So a page ends early, before that + * cut, and next_offset starts at the first expense it left out; the totals and + * the names next to the page fit in what is left. + */ +const PAGE_CHARS = 30_000; + +/** How much of an expense's note a page carries: enough to tell two similar expenses apart. */ +const NOTE_CHARS = 200; + +/** A split or payer list the service always fills; an empty one when it did not. */ +function rows(list: T[] | undefined): T[] { + return list ?? []; +} + +function shortNote(note: string | null | undefined): string | null { + const line = (note ?? '').replace(/\s+/g, ' ').trim(); + if (!line) return null; + return line.length > NOTE_CHARS ? `${line.slice(0, NOTE_CHARS - 1)}…` : line; +} + +/** + * One expense as the tool answers it: the fields a model plans with and a + * card shows, without the avatars, receipts and itemised ticket the REST row + * carries, and with the start of its note. Amounts stay in the expense's own + * currency, which is the trip's when the row names none. + */ +function slimItem(item: BudgetItem, tripCurrency: string) { + return { + id: item.id, + name: item.name, + category: item.category, + total_price: item.total_price, + currency: item.currency || tripCurrency, + expense_date: item.expense_date ?? null, + note: shortNote(item.note), + place_id: item.place_id ?? null, + reservation_id: item.reservation_id ?? null, + payers: rows(item.payers).map((p) => ({ user_id: p.user_id, amount: p.amount })), + members: rows(item.members).map((m) => ({ user_id: m.user_id, amount: m.amount ?? null, paid: m.paid === 1 })), + }; +} + +/** + * The expenses a page answers: the ones asked for, in order, until their JSON + * would pass PAGE_CHARS. Always at least one, so a page is never empty while + * expenses are left. + */ +function fillPage(items: BudgetItem[], tripCurrency: string) { + const page: ReturnType[] = []; + let chars = 0; + for (const item of items) { + const slim = slimItem(item, tripCurrency); + chars += JSON.stringify(slim).length + 1; + if (page.length > 0 && chars > PAGE_CHARS) break; + page.push(slim); + } + return page; +} + +/** The names behind the user ids on a page, once each, so a split reads as people rather than numbers. */ +function peopleOf(items: BudgetItem[]) { + const names = new Map(); + for (const item of items) { + for (const person of [...rows(item.members), ...rows(item.payers)]) { + if (person.username && !names.has(person.user_id)) names.set(person.user_id, person.username); + } + } + return [...names].sort(([a], [b]) => a - b).map(([user_id, username]) => ({ user_id, username })); +} + +/** + * The budget's read tools that answer with the rows themselves: the expense + * list (the twin of GET /api/trips/:tripId/budget) and the per-person totals + * (GET …/budget/summary/per-person). Same service calls and the same trip + * access as the routes, which ask for no permission beyond it; BudgetMcp has + * the writes and the settlement. A page of the list is a projection small + * enough for a model, with the totals of the whole budget next to it. + */ +@McpController() +export class BudgetItemsMcp { + constructor( + private readonly budget: BudgetService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'list_budget_items', + description: `List the trip's expenses with their ids, up to ${BUDGET_ITEMS_PAGE_DEFAULT} at a time from offset. A page of long expenses ends earlier, so always go on at next_offset (null on the last page). Each expense: name, category, total_price in its currency, expense_date, the start of its note, the place or booking it belongs to (place_id, reservation_id), who paid how much (payers) and who shares it (members: amount is the member's share on an uneven split, null on an equal one; paid says whether they paid their share). people names the user ids. Next to the page, over all expenses: the trip currency, the total and the totals per category in it, and unconverted_item_ids, foreign expenses no exchange rate converts, which count in no total. Use the ids with update_budget_item and delete_budget_item.`, + inputSchema: { tripId: idSchema, ...budgetItemsPageSchema.shape }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: budgetAddonOn, + access: { group: 'budget', mode: 'read' }, + }) + async listBudgetItems({ tripId, offset, limit }: { tripId: number } & BudgetItemsPage, ctx: McpContext) { + const trip = await this.budget.verifyTripAccess(tripId, ctx.userId); + if (!trip) return noAccess(); + const currency = (trip.currency || 'EUR').toUpperCase(); + const items = await this.budget.listBudgetItems(tripId); + // The figures get_trip_summary shows: each row at the rate it was booked at (#2525). + const totals = await this.budget.tripTotals( + tripId, + currency, + await this.budget.ratesForTripTotals(tripId, currency), + ); + const start = offset ?? 0; + const page = fillPage(items.slice(start, start + (limit ?? BUDGET_ITEMS_PAGE_DEFAULT)), currency); + const end = start + page.length; + return ok({ + currency, + total: totals.total, + by_category: totals.byCategory, + unconverted_item_ids: totals.unconverted, + item_count: items.length, + offset: start, + next_offset: end < items.length ? end : null, + items: page, + people: peopleOf(items.slice(start, end)), + }); + } + + @Tool({ + name: 'get_budget_per_person', + description: + "What each person on the trip's expenses owes in all and how much of that they paid, in the trip currency: per user total_assigned (their shares of the expenses they are part of), total_paid (the shares marked paid) and items_count. Each share is converted at the rate its expense was booked at; a foreign expense no exchange rate converts counts for nobody. For who has to pay whom to settle up, use get_settlement_summary.", + inputSchema: { tripId: idSchema }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: budgetAddonOn, + access: { group: 'budget', mode: 'read' }, + }) + async getBudgetPerPerson({ tripId }: { tripId: number }, ctx: McpContext) { + if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + return ok({ summary: await this.budget.perPersonSummary(tripId) }); + } +} diff --git a/server/src/nest/budget/budget.module.ts b/server/src/nest/budget/budget.module.ts index 940a4062c..c29c2a7fe 100644 --- a/server/src/nest/budget/budget.module.ts +++ b/server/src/nest/budget/budget.module.ts @@ -15,6 +15,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; +import { BudgetItemsMcp } from './budget-items.mcp'; import { BudgetController } from './budget.controller'; import { BudgetMcp } from './budget.mcp'; import { BudgetService } from './budget.service'; @@ -25,7 +26,8 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; /** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule. - * BudgetMcp carries the decorator-registered MCP tools + resources. + * BudgetMcp carries the decorator-registered MCP tools + resources, + * BudgetItemsMcp the reads that answer with the expense rows. * AuthModule is deliberately absent (BudgetMcp's demo guard reads * RuntimeEnvService + the users table, not AuthService) — that absence is * what lets AuthModule import BudgetModule for UserCleanupService. @@ -57,7 +59,7 @@ import { Module } from '@nestjs/common'; ]), ], controllers: [BudgetController], - providers: [BudgetService, ExchangeRatesService, BudgetMcp, ExchangeRatesRpc, CostsRpc], + providers: [BudgetService, ExchangeRatesService, BudgetMcp, BudgetItemsMcp, ExchangeRatesRpc, CostsRpc], // For in-container consumers (CostsRpc, TripsService, // ReservationsService, BookingImportService). exports: [BudgetService, ExchangeRatesService], diff --git a/server/src/nest/integrations/dawarich-atlas.mcp.ts b/server/src/nest/integrations/dawarich-atlas.mcp.ts new file mode 100644 index 000000000..9af41a83f --- /dev/null +++ b/server/src/nest/integrations/dawarich-atlas.mcp.ts @@ -0,0 +1,130 @@ +import { ADDON_IDS } from '../../addons'; +import { + McpController, + Tool, + type McpContext, + TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + TOOL_ANNOTATIONS_WRITE, + errorResult, + ok, +} from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { DawarichSuggestionsService } from './dawarich-suggestions.service'; +import { parseTimeWindow } from './dawarich.helpers'; +import { dawarichToolResult } from './dawarich.mcp'; +import { + DAWARICH_BUCKET_MATCH_MIN_MINUTES, + DAWARICH_BUCKET_MATCH_RADIUS_M, + DAWARICH_BUCKET_SCAN_LIMIT, + DAWARICH_WINDOW_MAX_DAYS, + dawarichAtlasAcceptSchema, + dawarichBucketConfirmSchema, + dawarichTimeWindowSchema, + idSchema, + type DawarichAtlasAccept, + type DawarichBucketConfirm, + type DawarichTimeWindow, +} from '@trek/shared'; + +/** Same gate as the controller's @RequireAddon(ADDON_IDS.DAWARICH): no addon, no tool. */ +const dawarichAddonOn = addonGate(ADDON_IDS.DAWARICH); + +/** + * The bucket list and the Atlas checked against the Dawarich recordings: the + * twins of `/api/integrations/dawarich/bucket-list/*` and `/atlas/*`, on the + * same service calls. + * + * Their own class rather than a part of DawarichMcp, because what they read + * and write is the Atlas: the assistant loads them with the atlas tools, where + * a question about the bucket list or the visited countries goes, while the + * stays and the recorded route stay with the journal. + * + * Reading the recordings to offer wishes and countries is `atlas:read`, as the + * answer is about the Atlas; ticking off and marking write it, `atlas:write`. + * Like the routes the tools work on the caller's own data and take no trip, + * and a refusal reads as the route's error sentence. + */ +@McpController() +export class DawarichAtlasMcp { + constructor( + private readonly suggestions: DawarichSuggestionsService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'scan_bucket_list_visits', + description: `Ask the caller's Dawarich recordings, wish by wish, whether they ever reached the places on their bucket list. A wish counts as reached by a stay of at least ${DAWARICH_BUCKET_MATCH_MIN_MINUTES} minutes within ${DAWARICH_BUCKET_MATCH_RADIUS_M} m; each entry carries that stay (start, minutes, distance, recorded points) or null, and whether the wish was ticked off already. At most ${DAWARICH_BUCKET_SCAN_LIMIT} wishes per scan (truncated says more are left); wishes without coordinates are only counted. An instance that cannot be reached ends the scan with an error rather than a list of misses. Nothing is ticked off here: confirm_bucket_list_visits does that once the user agrees.`, + inputSchema: {}, + // Asks the caller's Dawarich instance once per wish and writes nothing. + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + access: { group: 'atlas', mode: 'read' }, + when: dawarichAddonOn, + }) + async scanBucketList(_args: unknown, ctx: McpContext) { + return dawarichToolResult(() => this.suggestions.scanBucketList(ctx.userId)); + } + + @Tool({ + name: 'confirm_bucket_list_visits', + description: + "Tick off the caller's bucket-list wishes the user confirmed after scan_bucket_list_visits (1 to 200 ids). Each is dated with visitedAt (ISO 8601) when given, otherwise with the start of the stay it was matched to, otherwise today. A wish ticked off already keeps its date. Answers how many were ticked off now (updated).", + inputSchema: dawarichBucketConfirmSchema.shape, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'atlas', mode: 'write' }, + when: dawarichAddonOn, + }) + async confirmBucketVisits({ itemIds, visitedAt }: DawarichBucketConfirm, ctx: McpContext) { + return dawarichToolResult(async () => ({ + updated: await this.suggestions.confirmBucketVisits(ctx.userId, itemIds, visitedAt), + })); + } + + @Tool({ + name: 'clear_bucket_list_visit', + description: + "Take the visited mark off one of the caller's bucket-list wishes, with its date and where it came from, so the wish reads as not reached yet. The undo of confirm_bucket_list_visits and mark_bucket_list_item_visited_from_dawarich.", + inputSchema: { itemId: idSchema.describe('The bucket-list entry') }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'atlas', mode: 'write' }, + when: dawarichAddonOn, + }) + async clearBucketVisit({ itemId }: { itemId: number }, ctx: McpContext) { + // Someone else's wish answers like a missing one, as the route's 404 does. + if (!(await this.suggestions.clearBucketVisit(ctx.userId, itemId))) { + return errorResult('Bucket-list entry not found'); + } + return ok({ success: true }); + } + + @Tool({ + name: 'suggest_atlas_countries', + description: `List the countries the caller's Dawarich recordings put them in between from and to (at most ${DAWARICH_WINDOW_MAX_DAYS} days), each with its cities, the minutes spent there and whether the Atlas counts the country as visited already. Country names TREK could not match to a code are listed under unresolved. Nothing is marked here: accept_atlas_countries does that for the codes the user confirms.`, + inputSchema: dawarichTimeWindowSchema.shape, + // Reads the caller's Dawarich instance live and writes nothing. + annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, + access: { group: 'atlas', mode: 'read' }, + when: dawarichAddonOn, + }) + async suggestAtlasCountries({ from, to }: DawarichTimeWindow, ctx: McpContext) { + // The route's window check, refused before the instance is asked. + const window = parseTimeWindow(from, to); + return dawarichToolResult(() => this.suggestions.atlasSuggestions(ctx.userId, window.from, window.to)); + } + + @Tool({ + name: 'accept_atlas_countries', + description: + "Mark countries the user confirmed from suggest_atlas_countries as visited in the caller's Atlas, by ISO 3166-1 alpha-2 code (1 to 300 at once). A country taken off the Atlas before comes back; one marked already keeps where it came from. Answers how many were added now (marked).", + inputSchema: dawarichAtlasAcceptSchema.shape, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'atlas', mode: 'write' }, + when: dawarichAddonOn, + }) + async acceptAtlasCountries({ countryCodes }: DawarichAtlasAccept, ctx: McpContext) { + return dawarichToolResult(async () => ({ + marked: await this.suggestions.acceptAtlasCountries(ctx.userId, countryCodes), + })); + } +} diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts index eab1cfe46..ee1799816 100644 --- a/server/src/nest/integrations/dawarich-suggestions.service.ts +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -20,7 +20,7 @@ import { JourneyDomainService } from '../journey/journey-domain.service'; import { JourneyEntryStaysService } from '../journey/journey-entry-stays.service'; import { PermissionsService } from '../permissions/permissions.service'; import { PlacesService } from '../places/places.service'; -import { DawarichClient, type DawarichCreds } from './dawarich.client'; +import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client'; import { minutesBetween, toNumber } from './dawarich.helpers'; import { DawarichService } from './dawarich.service'; import { InjectRepository } from '@mikro-orm/nestjs'; @@ -37,8 +37,21 @@ import { type DawarichBucketScan, type DawarichSuggestion, type DawarichSuggestionList, + type DawarichErrorCode, } from '@trek/shared'; +/** + * The failures that are the instance's rather than one request's: it cannot + * be reached (a timeout included), it turns the key away, or it asks TREK to + * slow down. A bucket-list scan stops at the first of them. + */ +const INSTANCE_FAILURES: ReadonlySet = new Set([ + 'unreachable', + 'unauthorized', + 'forbidden', + 'rate_limited', +]); + /** * What a user does with the stays a sync brought in. * @@ -408,6 +421,11 @@ export class DawarichSuggestionsService { * One upstream request per wish, so it is capped and explicitly triggered * rather than run on a timer. The cap is reported, never silent: a user with * eighty wishes must not be told their other thirty had no match. + * + * For the same reason an instance that cannot answer at all ends the scan + * with its error instead of reporting every wish as unmatched: one that does + * not answer would otherwise hold the request for a timeout per wish, and + * one that refuses the key would read as "you never got anywhere". */ async scanBucketList(userId: number): Promise { const creds = await this.dawarich.getCredentials(userId); @@ -447,7 +465,10 @@ export class DawarichSuggestionsService { let stays; try { stays = await this.client.findVisitsNear(creds, lat, lng, DAWARICH_BUCKET_MATCH_RADIUS_M, 20); - } catch { + } catch (err) { + // The instance itself is down or turns the key away: every later wish + // would fail the same way, so the scan ends here with that error. + if (err instanceof DawarichError && INSTANCE_FAILURES.has(err.code)) throw err; // One wish that could not be checked is not a failed scan; it reports as // "no match found" and the user can run it again. return null; diff --git a/server/src/nest/integrations/dawarich.controller.ts b/server/src/nest/integrations/dawarich.controller.ts index 365523f9e..0825d2b82 100644 --- a/server/src/nest/integrations/dawarich.controller.ts +++ b/server/src/nest/integrations/dawarich.controller.ts @@ -16,6 +16,7 @@ import { DawarichSettingsDto, DawarichSuggestionStateDto, } from './dawarich.dto'; +import { parseTimeWindow } from './dawarich.helpers'; import { DawarichService } from './dawarich.service'; import { Body, @@ -176,7 +177,7 @@ export class DawarichController { @Get('atlas/suggestions') async atlasSuggestions(@CurrentUser() user: User, @Query('from') from?: string, @Query('to') to?: string) { - const window = parseWindow(from, to); + const window = parseTimeWindow(from, to); return this.guardAsync(() => this.suggestions.atlasSuggestions(user.id, window.from, window.to)); } @@ -215,7 +216,7 @@ export class DawarichController { @Query('to') to?: string, @Query('offset') offset?: string, ) { - const window = parseWindow(from, to); + const window = parseTimeWindow(from, to); return this.guardAsync(() => this.tracks.forWindow(user.id, window.from.toISOString(), window.to.toISOString(), parseOffset(offset)), ); @@ -310,25 +311,6 @@ function parseDate(raw: string | undefined): string | undefined { return raw; } -/** - * An explicit instant window. Both ends are required together: a half-open - * range would silently become "everything since the epoch" on one side, which - * is the request that makes an instance time out. - */ -function parseWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } { - const start = from ? new Date(from) : null; - const end = to ? new Date(to) : null; - if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) { - throw new HttpException({ error: 'from and to are required ISO timestamps' }, 400); - } - if (end <= start) throw new HttpException({ error: 'to must be after from' }, 400); - const MAX_DAYS = 400; - if (end.getTime() - start.getTime() > MAX_DAYS * 86_400_000) { - throw new HttpException({ error: `Window must be ${MAX_DAYS} days or less` }, 400); - } - return { from: start, to: end }; -} - /** The originating client's socket id, so a broadcast does not echo back to it. */ function socketId(req: Request): string | undefined { const header = req.headers['x-socket-id']; diff --git a/server/src/nest/integrations/dawarich.helpers.ts b/server/src/nest/integrations/dawarich.helpers.ts index c9494df58..36357ca2f 100644 --- a/server/src/nest/integrations/dawarich.helpers.ts +++ b/server/src/nest/integrations/dawarich.helpers.ts @@ -1,10 +1,16 @@ +import { DomainError } from '../common/domain-error'; import type { DawarichSlimPoint, DawarichTrackFeature, DawarichVisitRaw, DawarichVisitedCountry, } from './dawarich.client'; -import { RECORDING_GAP_METERS, type DawarichTrackDay, type DawarichTrackSegment } from '@trek/shared'; +import { + DAWARICH_WINDOW_MAX_DAYS, + RECORDING_GAP_METERS, + type DawarichTrackDay, + type DawarichTrackSegment, +} from '@trek/shared'; import { createHash } from 'crypto'; @@ -690,3 +696,25 @@ function sumOf(a: unknown, b: unknown): number | undefined { if (left === null && right === null) return undefined; return (left ?? 0) + (right ?? 0); } + +// ── Windows ────────────────────────────────────────────────────────────────── + +/** + * An explicit instant window, read the one way for the REST routes and the + * MCP tools that take one. Both ends are required together: a half-open range + * would silently become "everything since the epoch" on one side, which is the + * request that makes an instance time out. A refusal is a 400 that REST and + * MCP answer with the same sentence. + */ +export function parseTimeWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } { + const start = from ? new Date(from) : null; + const end = to ? new Date(to) : null; + if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) { + throw new DomainError(400, 'from and to are required ISO timestamps'); + } + if (end <= start) throw new DomainError(400, 'to must be after from'); + if (end.getTime() - start.getTime() > DAWARICH_WINDOW_MAX_DAYS * 86_400_000) { + throw new DomainError(400, `Window must be ${DAWARICH_WINDOW_MAX_DAYS} days or less`); + } + return { from: start, to: end }; +} diff --git a/server/src/nest/integrations/dawarich.mcp.ts b/server/src/nest/integrations/dawarich.mcp.ts index e823b8427..d57df20b1 100644 --- a/server/src/nest/integrations/dawarich.mcp.ts +++ b/server/src/nest/integrations/dawarich.mcp.ts @@ -13,6 +13,7 @@ import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; import { AcceptError, DawarichSuggestionsService } from './dawarich-suggestions.service'; import { DawarichTracksService } from './dawarich-tracks.service'; +import { DawarichError } from './dawarich.client'; import { idSchema } from '@trek/shared'; import { z } from 'zod'; @@ -48,6 +49,9 @@ const MAX_LIMIT = 200; * The connection routes (settings, test, disconnect) are deliberately absent: * they take an API key for somebody's complete location history, and that is * exactly what no assistant scope should be able to read or replace. + * + * The bucket-list and Atlas twins of `/bucket-list/*` and `/atlas/*` are + * DawarichAtlasMcp, which the assistant loads with the Atlas. */ @McpController() export class DawarichMcp { @@ -251,11 +255,22 @@ export class DawarichMcp { /** The MCP echo of the controller's error shaping: a refusal, not an exception. */ private async run(action: () => Promise) { - try { - return ok((await action()) as object); - } catch (err) { - if (err instanceof AcceptError) return errorResult(err.message); - throw err; - } + return dawarichToolResult(action); + } +} + +/** + * A Dawarich tool's answer: the action's result, or a refusal in its words + * when it fails as the routes refuse. An instance that cannot be read is a + * refusal too, with TREK's own sentence for it (the upstream text stays in + * the error's detail, as on the route). Anything else propagates. Shared with + * DawarichAtlasMcp. + */ +export async function dawarichToolResult(action: () => Promise) { + try { + return ok((await action()) as object); + } catch (err) { + if (err instanceof AcceptError || err instanceof DawarichError) return errorResult(err.message); + throw err; } } diff --git a/server/src/nest/integrations/dawarich.module.ts b/server/src/nest/integrations/dawarich.module.ts index ed94eeb3d..08ecd8397 100644 --- a/server/src/nest/integrations/dawarich.module.ts +++ b/server/src/nest/integrations/dawarich.module.ts @@ -14,6 +14,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { PlacesModule } from '../places/places.module'; import { SchedulingModule } from '../scheduling/scheduling.module'; +import { DawarichAtlasMcp } from './dawarich-atlas.mcp'; import { DawarichDayStaysService } from './dawarich-day-stays.service'; import { DawarichSuggestionsService } from './dawarich-suggestions.service'; import { DawarichSyncJob } from './dawarich-sync.job'; @@ -90,6 +91,7 @@ import { Module } from '@nestjs/common'; DawarichDayStaysService, DawarichSyncJob, DawarichMcp, + DawarichAtlasMcp, ], exports: [DawarichService, DawarichSuggestionsService, DawarichTracksService, DawarichDayStaysService], }) diff --git a/server/src/nest/journey/journey-tracks.mcp.ts b/server/src/nest/journey/journey-tracks.mcp.ts new file mode 100644 index 000000000..33db9e0a7 --- /dev/null +++ b/server/src/nest/journey/journey-tracks.mcp.ts @@ -0,0 +1,82 @@ +import { ADDON_IDS } from '../../addons'; +import { McpController, TOOL_ANNOTATIONS_READONLY, Tool, errorResult, ok, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { JourneyDomainService } from './journey-domain.service'; +import { routeDistance, thinRoute } from './journey-stats'; +import { idSchema, type JourneyTrack } from '@trek/shared'; + +const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY); + +/** Points one track keeps at most: enough for its shape on a small map. */ +const TRACK_DISPLAY_POINTS = 200; + +/** + * Points all tracks of an answer keep together, so a journey of many trips + * does not grow the answer by a full line per track. Each track keeps its two + * ends whatever the share. + */ +const TRACKS_DISPLAY_POINTS = 1000; + +/** How many points each of `count` tracks keeps: an even share of the total, between 2 and the per-track cap. */ +function pointsPerTrack(count: number): number { + return Math.max(2, Math.min(TRACK_DISPLAY_POINTS, Math.floor(TRACKS_DISPLAY_POINTS / Math.max(1, count)))); +} + +/** A coordinate to five decimals, about a metre: finer than any drawn line shows. */ +function coordinate(value: number): number { + return Math.round(value * 1e5) / 1e5; +} + +/** + * One track as the tool answers it: what the model reasons with (which trip + * and place it belongs to, its name and kind, how long it is) and, under + * `display_geometry`, the line thinned for an MCP client that draws it. No + * card in TREK's own assistant draws it: the assistant drops every key that + * names a geometry before the answer reaches the provider, so there the + * points cost the model nothing. + */ +function trackSummary(track: JourneyTrack, points: number) { + const distance = routeDistance(track.points.map(([lat, lng]) => ({ lat, lng }))); + return { + place_id: track.place_id, + trip_id: track.trip_id, + name: track.name, + // Absent means a trip's GPX, all GET :id/tracks has ever answered. + kind: track.kind ?? 'trip_gpx', + color: track.color, + point_count: track.points.length, + distance_km: Math.round(distance / 100) / 10, + display_geometry: thinRoute(track.points, points).map(([lat, lng]) => [coordinate(lat), coordinate(lng)]), + }; +} + +/** + * The GPX tracks drawn on a journey's map over MCP: the same service and the + * same journey access as GET /api/journeys/:id/tracks, read under the Journey + * addon like the route. A track's full geometry can run to thousands of + * points, so each answers with its size and a thinned line instead, the + * lines of all tracks together held to TRACKS_DISPLAY_POINTS. + */ +@McpController() +export class JourneyTracksMcp { + constructor( + private readonly journey: JourneyDomainService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'get_journey_tracks', + description: `The GPX tracks a journey's map draws, from the trips added to it: per track the place and trip it belongs to, its name, its kind (trip_gpx for a trip's GPX), how many points it has and its length in km as the line runs. display_geometry holds the line thinned for drawing as [lat, lng] points: at most ${TRACK_DISPLAY_POINTS} per track and about ${TRACKS_DISPLAY_POINTS} for all tracks together, always with both ends. A journey whose trips have no tracks answers an empty list.`, + inputSchema: { journeyId: idSchema }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: journeyAddonOn, + access: { group: 'journey', mode: 'read' }, + }) + async getJourneyTracks({ journeyId }: { journeyId: number }, ctx: McpContext) { + const tracks = await this.journey.journeyTracks(journeyId, ctx.userId); + if (!tracks) return errorResult('Journey not found or access denied.'); + const points = pointsPerTrack(tracks.length); + return ok({ tracks: tracks.map((track) => trackSummary(track, points)) }); + } +} diff --git a/server/src/nest/journey/journey.module.ts b/server/src/nest/journey/journey.module.ts index 4a4d19e40..42074fed3 100644 --- a/server/src/nest/journey/journey.module.ts +++ b/server/src/nest/journey/journey.module.ts @@ -14,6 +14,7 @@ import { JourneyMapController } from './journey-map.controller'; import { JourneyMapMcp } from './journey-map.mcp'; import { JourneyPhotoCaptureModule } from './journey-photo-capture.module'; import { JourneyPublicController } from './journey-public.controller'; +import { JourneyTracksMcp } from './journey-tracks.mcp'; import { JourneyController } from './journey.controller'; import { journeyImageFileFilter, journeyUploadFilename } from './journey.controller'; import { JourneyMcp } from './journey.mcp'; @@ -53,6 +54,6 @@ import { MulterModule } from '@nestjs/platform-express'; MikroOrmModule.forFeature([Users, JourneyBooks]), ], controllers: [JourneyController, JourneyMapController, JourneyPublicController], - providers: [JourneyService, JourneyBookService, JourneyMcp, JourneyMapMcp], + providers: [JourneyService, JourneyBookService, JourneyMcp, JourneyMapMcp, JourneyTracksMcp], }) export class JourneyModule {} diff --git a/server/src/nest/weather/weather.mcp.ts b/server/src/nest/weather/weather.mcp.ts index 6980a82de..7ea4ae493 100644 --- a/server/src/nest/weather/weather.mcp.ts +++ b/server/src/nest/weather/weather.mcp.ts @@ -1,5 +1,7 @@ import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; import { WeatherService } from './weather.service'; +import { WindService } from './wind.service'; +import { WIND_FORECAST_DAYS, windQuerySchema, type WindQuery } from '@trek/shared'; import { z } from 'zod'; @@ -13,10 +15,15 @@ import { z } from 'zod'; * The catch that answers with the upstream message and `isError: true` rather * than throwing is carried over verbatim: the client sees "Weather service not * available." instead of a transport-level failure when the provider is down. + * get_wind_forecast, the twin of GET /api/weather/wind, answers its failures + * the same way. */ @McpController() export class WeatherMcp { - constructor(private readonly weather: WeatherService) {} + constructor( + private readonly weather: WeatherService, + private readonly wind: WindService, + ) {} @Tool({ name: 'get_weather', @@ -38,10 +45,7 @@ export class WeatherMcp { const weather = await this.weather.get(String(lat), String(lng), date, lang ?? 'en'); return ok({ weather }); } catch (err) { - return { - content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }], - isError: true, - }; + return unavailable(err); } } @@ -65,10 +69,30 @@ export class WeatherMcp { const weather = await this.weather.getDetailed(String(lat), String(lng), date, lang ?? 'en'); return ok({ weather }); } catch (err) { - return { - content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }], - isError: true, - }; + return unavailable(err); + } + } + + @Tool({ + name: 'get_wind_forecast', + description: `Get the wind forecast for a location (lat, lng) on one day (date, YYYY-MM-DD): per local hour the mean speed and the gusts in km/h and the direction the wind comes from in degrees, and for the day the strongest mean, the strongest gust and the dominant direction. Only a forecast is answered: a day in the past or more than ${WIND_FORECAST_DAYS} days ahead comes back with its status (past, beyond_horizon) and no figures. A value the source did not give is null, never calm.`, + inputSchema: windQuerySchema.shape, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'weather', mode: 'read' }, + }) + async getWindForecast({ lat, lng, date }: WindQuery, _ctx: McpContext) { + try { + return ok({ wind: await this.wind.forecast({ lat, lng, date }) }); + } catch (err) { + return unavailable(err); } } } + +/** The provider's failure as a refusal the caller can read, never a transport error. */ +function unavailable(err: unknown) { + return { + content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }], + isError: true, + }; +} diff --git a/server/tests/e2e/budget.e2e.test.ts b/server/tests/e2e/budget.e2e.test.ts index 6e071a1d7..11026a912 100644 --- a/server/tests/e2e/budget.e2e.test.ts +++ b/server/tests/e2e/budget.e2e.test.ts @@ -13,10 +13,13 @@ import { BudgetSettlements } from '../../src/db/entities/BudgetSettlements.entit import { Reservations } from '../../src/db/entities/Reservations.entity'; import { TripMembers } from '../../src/db/entities/TripMembers.entity'; import { Trips } from '../../src/db/entities/Trips.entity'; +import type { McpContext } from '../../src/nest-mcp'; +import { BudgetItemsMcp } from '../../src/nest/budget/budget-items.mcp'; import { BudgetModule } from '../../src/nest/budget/budget.module'; import { ExchangeRatesService } from '../../src/nest/budget/exchange-rates.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { withRequestContext } from '../../src/nest/database/request-context'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; @@ -29,6 +32,7 @@ import { makeUser } from '../helpers/factories/users'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { sessionCookie } from './harness'; +import { MikroORM } from '@mikro-orm/core'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; @@ -861,4 +865,77 @@ describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => { expect(balances.find((b) => b.user_id === 3)?.balance).toBe(0); }); }); + + describe('the read tools over MCP', () => { + let mcpTripId: number; + const asUser = (userId: number) => ({ userId }) as McpContext; + const call = (fn: () => Promise) => withRequestContext(app.get(MikroORM), fn); + const text = (result: { content: { text: string }[] }) => JSON.parse(result.content[0]?.text ?? ''); + + beforeAll(async () => { + mcpTripId = (await makeTrip(orm, 1, { title: 'MCP Trip', currency: 'EUR' })).id; + await addTripMember(orm, mcpTripId, 2); + await makeUser(orm, { + id: 6, + username: 'e2e-mcp-outsider', + email: 'mcp-outsider@example.test', + password_version: 0, + }); + }); + + it('BUDGET-E2E-001: list_budget_items answers the expenses REST lists, in its order, with the trip totals', async () => { + const create = (body: object) => + request(server).post(`/api/trips/${mcpTripId}/budget`).set('Cookie', sessionCookie(1)).send(body); + // One after the other, so the list order is the order they were made in. + const ferry = await create({ name: 'Ferry', category: 'transport', total_price: 60, member_ids: [1, 2] }); + const diner = await create({ + name: 'Diner', + category: 'food', + currency: 'USD', + exchange_rate: 1.2, + payers: [{ user_id: 2, amount: 36 }], + }); + expect([ferry.status, diner.status]).toEqual([201, 201]); + const rest = await request(server).get(`/api/trips/${mcpTripId}/budget`).set('Cookie', sessionCookie(1)); + expect(rest.status).toBe(200); + + const tool = text(await call(() => app.get(BudgetItemsMcp).listBudgetItems({ tripId: mcpTripId }, asUser(2)))); + + type RestItem = { id: number; name: string; total_price: number; currency: string | null; members: unknown[] }; + expect(tool.items.map((item: { id: number }) => item.id)).toEqual(rest.body.items.map((i: RestItem) => i.id)); + expect(tool.items.map((item: { name: string; total_price: number }) => [item.name, item.total_price])).toEqual( + rest.body.items.map((i: RestItem) => [i.name, i.total_price]), + ); + // 60 EUR and 36 USD at 1.2: 30 EUR. + expect(tool).toMatchObject({ currency: 'EUR', total: 90, item_count: 2, next_offset: null }); + }); + + it('BUDGET-E2E-002: get_budget_per_person answers exactly what the per-person route answers', async () => { + const rest = await request(server) + .get(`/api/trips/${mcpTripId}/budget/summary/per-person`) + .set('Cookie', sessionCookie(1)); + expect(rest.status).toBe(200); + + const tool = text(await call(() => app.get(BudgetItemsMcp).getBudgetPerPerson({ tripId: mcpTripId }, asUser(1)))); + + expect(tool).toEqual(rest.body); + expect(tool.summary.length).toBeGreaterThan(0); + }); + + it('BUDGET-E2E-003: someone off the trip gets the REST 404 and the MCP trip refusal from both tools', async () => { + const rest = await request(server).get(`/api/trips/${mcpTripId}/budget`).set('Cookie', sessionCookie(6)); + expect(rest.status).toBe(404); + + const mcp = app.get(BudgetItemsMcp); + for (const result of [ + await call(() => mcp.listBudgetItems({ tripId: mcpTripId }, asUser(6))), + await call(() => mcp.getBudgetPerPerson({ tripId: mcpTripId }, asUser(6))), + ]) { + expect(result).toEqual({ + content: [{ type: 'text', text: 'Trip not found or access denied.' }], + isError: true, + }); + } + }); + }); }); diff --git a/server/tests/e2e/dawarich.e2e.test.ts b/server/tests/e2e/dawarich.e2e.test.ts index 3f7d5feda..cf20090c1 100644 --- a/server/tests/e2e/dawarich.e2e.test.ts +++ b/server/tests/e2e/dawarich.e2e.test.ts @@ -15,21 +15,31 @@ * 401, the shape of a connection nobody has configured, the two URL rejections * that need no DNS, the suggestions envelope's `connected: false`, and the * cross-user isolation — someone else's suggestion is invisible and untouchable - * through your own session, and answers the same 404 a missing one does. + * through your own session, and answers the same 404 a missing one does. The + * bucket-list and Atlas tools of DawarichAtlasMcp run next to their routes on + * the same container, so the two channels are held to the same answers. */ import { db } from '../../src/db/database'; +import { BucketList } from '../../src/db/entities/BucketList.entity'; import { DawarichConnections } from '../../src/db/entities/DawarichConnections.entity'; import { DawarichVisitSuggestions } from '../../src/db/entities/DawarichVisitSuggestions.entity'; +import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity'; +import { trekMcpErrorMapper } from '../../src/mcp/nest-mcp-policy'; +import type { McpContext, McpTextResult } from '../../src/nest-mcp'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; -import { DawarichClient } from '../../src/nest/integrations/dawarich.client'; +import { withRequestContext } from '../../src/nest/database/request-context'; +import { DawarichAtlasMcp } from '../../src/nest/integrations/dawarich-atlas.mcp'; +import { DawarichClient, DawarichError } from '../../src/nest/integrations/dawarich.client'; import { DawarichModule } from '../../src/nest/integrations/dawarich.module'; import { createUser } from '../helpers/factories'; +import { makeBucketListItem } from '../helpers/factories/atlas'; import { countRows, deleteRows, findRow, insertRow } from '../helpers/factories/rows'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { sessionCookie } from './harness'; +import { MikroORM } from '@mikro-orm/core'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; @@ -377,4 +387,126 @@ describe('Dawarich e2e (real addon gate + real auth guard + real services + temp expect(res.body.state).toBe('dismissed'); expect((await findRow(orm, DawarichVisitSuggestions, { id: mine }))?.state).toBe('dismissed'); }); + + // ── Bucket list and Atlas over MCP: the same services, the same answers ── + + const asUser = (userId: number) => ({ userId, scopes: null, isStaticToken: false }) as McpContext; + const viaMcp = (fn: (mcp: DawarichAtlasMcp) => Promise) => + withRequestContext(app.get(MikroORM), () => fn(app.get(DawarichAtlasMcp))); + const answerOf = (result: McpTextResult) => JSON.parse(result.content[0]?.text ?? ''); + + it('DAWARICH-E2E-040: a wish ticked off over MCP is ticked off for REST, and only its owner can clear it', async () => { + const wish = await makeBucketListItem(orm, ownerId, { name: 'Alhambra', lat: 37.176, lng: -3.588 }); + + const confirmed = await viaMcp((mcp) => + mcp.confirmBucketVisits({ itemIds: [wish.id], visitedAt: '2024-04-02T10:00:00Z' }, asUser(ownerId)), + ); + expect(answerOf(confirmed)).toEqual({ updated: 1 }); + const again = await request(server) + .post('/api/integrations/dawarich/bucket-list/confirm') + .set('Cookie', sessionCookie(ownerId)) + .send({ itemIds: [wish.id] }); + expect(again.status).toBe(200); + expect(again.body).toEqual({ updated: 0 }); + expect(await findRow(orm, BucketList, { id: wish.id })).toMatchObject({ + visited_at: '2024-04-02T10:00:00Z', + visited_source: 'dawarich', + }); + + const foreign = await viaMcp((mcp) => mcp.clearBucketVisit({ itemId: wish.id }, asUser(strangerId))); + expect(foreign).toEqual({ content: [{ type: 'text', text: 'Bucket-list entry not found' }], isError: true }); + const foreignRest = await request(server) + .delete(`/api/integrations/dawarich/bucket-list/${wish.id}/visit`) + .set('Cookie', sessionCookie(strangerId)); + expect(foreignRest.status).toBe(404); + expect(foreignRest.body).toEqual({ error: 'Bucket-list entry not found' }); + + expect(answerOf(await viaMcp((mcp) => mcp.clearBucketVisit({ itemId: wish.id }, asUser(ownerId))))).toEqual({ + success: true, + }); + expect(await findRow(orm, BucketList, { id: wish.id })).toMatchObject({ visited_at: null, visited_source: null }); + }); + + it('DAWARICH-E2E-041: countries accepted over MCP land in the Atlas once, as REST then finds them', async () => { + const marked = await viaMcp((mcp) => mcp.acceptAtlasCountries({ countryCodes: ['PT', 'es'] }, asUser(ownerId))); + expect(answerOf(marked)).toEqual({ marked: 2 }); + + const again = await request(server) + .post('/api/integrations/dawarich/atlas/accept') + .set('Cookie', sessionCookie(ownerId)) + .send({ countryCodes: ['PT', 'ES'] }); + expect(again.status).toBe(200); + expect(again.body).toEqual({ marked: 0 }); + expect(await countRows(orm, VisitedCountries, { user: ownerId, source: 'dawarich' })).toBe(2); + }); + + it('DAWARICH-E2E-042: without a connection both reads refuse alike, and a backwards window is the route 400 before anything is asked', async () => { + const scan = await request(server) + .post('/api/integrations/dawarich/bucket-list/scan') + .set('Cookie', sessionCookie(ownerId)); + expect(scan.status).toBe(400); + expect(scan.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' }); + expect(await viaMcp((mcp) => mcp.scanBucketList({}, asUser(ownerId)))).toEqual({ + content: [{ type: 'text', text: 'Dawarich is not connected' }], + isError: true, + }); + + const window = { from: '2026-01-01T00:00:00Z', to: '2026-03-01T00:00:00Z' }; + const suggestions = await request(server) + .get('/api/integrations/dawarich/atlas/suggestions') + .query(window) + .set('Cookie', sessionCookie(ownerId)); + expect(suggestions.status).toBe(400); + expect(suggestions.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' }); + expect(await viaMcp((mcp) => mcp.suggestAtlasCountries(window, asUser(ownerId)))).toEqual({ + content: [{ type: 'text', text: 'Dawarich is not connected' }], + isError: true, + }); + + const backwards = { from: window.to, to: window.from }; + const refused = await request(server) + .get('/api/integrations/dawarich/atlas/suggestions') + .query(backwards) + .set('Cookie', sessionCookie(ownerId)); + expect(refused.status).toBe(400); + expect(refused.body).toEqual({ error: 'to must be after from' }); + const thrown = await viaMcp((mcp) => mcp.suggestAtlasCountries(backwards, asUser(ownerId))).then( + () => undefined, + (err: unknown) => err, + ); + expect(thrown).toMatchObject({ status: 400, publicMessage: 'to must be after from' }); + // The tool call answers with the route's sentence: the registry maps the refusal to an error result. + expect(trekMcpErrorMapper(thrown)).toEqual({ + content: [{ type: 'text', text: 'to must be after from' }], + isError: true, + }); + }); + + it('DAWARICH-E2E-043: an instance that does not answer ends a scan after one lookup: the route 502 and the same sentence over MCP', async () => { + await insertRow(orm, DawarichConnections, { + user: ownerId, + url: 'https://dawarich.example', + api_key: 'plain-legacy-key', + }); + await Promise.all( + ['Alhambra', 'Petra'].map((name) => makeBucketListItem(orm, ownerId, { name, lat: 37.176, lng: -3.588 })), + ); + const findVisitsNear = vi.mocked(app.get(DawarichClient).findVisitsNear); + findVisitsNear.mockClear(); + const down = () => new DawarichError('unreachable', 'Could not reach Dawarich', undefined, 'ETIMEDOUT'); + findVisitsNear.mockRejectedValueOnce(down()).mockRejectedValueOnce(down()); + + const scan = await request(server) + .post('/api/integrations/dawarich/bucket-list/scan') + .set('Cookie', sessionCookie(ownerId)); + expect(scan.status).toBe(502); + expect(scan.body).toEqual({ error: 'Could not reach Dawarich', code: 'unreachable', detail: 'ETIMEDOUT' }); + expect(await viaMcp((mcp) => mcp.scanBucketList({}, asUser(ownerId)))).toEqual({ + content: [{ type: 'text', text: 'Could not reach Dawarich' }], + isError: true, + }); + // One lookup per channel, not one per wish. + expect(findVisitsNear).toHaveBeenCalledTimes(2); + await deleteRows(orm, BucketList, { user: ownerId }); + }); }); diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 28bfac82d..3605efa1b 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -5,6 +5,7 @@ * ordering (404 wins over 401), auth, the service-owned 403/404 mapping, status * codes and the unguarded public route. */ +import type { McpContext } from '../../src/nest-mcp'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { notFound } from '../../src/nest/common/domain-error'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; @@ -13,6 +14,7 @@ import { JourneyBookService } from '../../src/nest/journey/journey-book.service' import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; import { JourneyMapService } from '../../src/nest/journey/journey-map.service'; import { JourneyShareService } from '../../src/nest/journey/journey-share.service'; +import { JourneyTracksMcp } from '../../src/nest/journey/journey-tracks.mcp'; import { JourneyModule } from '../../src/nest/journey/journey.module'; import { PhotoCaptureBackfillService } from '../../src/nest/memories/photo-capture-backfill.service'; import { StorageService } from '../../src/nest/storage/storage.service'; @@ -102,6 +104,7 @@ const { jsvc } = vi.hoisted(() => ({ uploadGalleryPhotos: vi.fn(), broadcastJourneyEvent: vi.fn(), journeyIdOfEntry: vi.fn(), + journeyTracks: vi.fn(), }, })); @@ -616,4 +619,59 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { expect(missing.status).toBe(404); expect(missing.body).toEqual({ error: 'Not found' }); }); + + // ── GPX tracks: the route and get_journey_tracks read the same service ──── + + const TRACK = { + place_id: 4, + trip_id: 2, + name: 'Lagoon loop', + color: '#1971c2', + points: [ + [45.43, 12.33], + [45.44, 12.34], + ] as [number, number][], + }; + const asMcp = { userId: 1 } as McpContext; + + it('JOURNEY-E2E-001: the tracks route and get_journey_tracks answer the same tracks, the tool by size with a thinned line', async () => { + jsvc.journeyTracks.mockReset(); + jsvc.journeyTracks.mockResolvedValue([TRACK]); + + const res = await request(server).get('/api/journeys/9/tracks').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ tracks: [TRACK] }); + + const tool = await app.get(JourneyTracksMcp).getJourneyTracks({ journeyId: 9 }, asMcp); + expect(JSON.parse(tool.content[0]?.text ?? '')).toEqual({ + tracks: [ + { + place_id: 4, + trip_id: 2, + name: 'Lagoon loop', + kind: 'trip_gpx', + color: '#1971c2', + point_count: 2, + distance_km: 1.4, + display_geometry: TRACK.points, + }, + ], + }); + expect(jsvc.journeyTracks.mock.calls).toEqual([ + [9, 1], + [9, 1], + ]); + }); + + it('JOURNEY-E2E-002: a journey out of reach is a 404 on the route and the journey refusal over MCP', async () => { + jsvc.journeyTracks.mockReset(); + jsvc.journeyTracks.mockResolvedValue(null); + + const res = await request(server).get('/api/journeys/9/tracks').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Journey not found' }); + + const tool = await app.get(JourneyTracksMcp).getJourneyTracks({ journeyId: 9 }, asMcp); + expect(tool).toEqual({ content: [{ type: 'text', text: 'Journey not found or access denied.' }], isError: true }); + }); }); diff --git a/server/tests/e2e/weather.e2e.test.ts b/server/tests/e2e/weather.e2e.test.ts index 5c09ce7f2..95990c6cb 100644 --- a/server/tests/e2e/weather.e2e.test.ts +++ b/server/tests/e2e/weather.e2e.test.ts @@ -3,8 +3,13 @@ * real JwtAuthGuard against a temp SQLite db (seeded via the shared harness). * The weather service is mocked so no real Open-Meteo calls happen. */ +import type { McpContext } from '../../src/nest-mcp'; +import { DomainError } from '../../src/nest/common/domain-error'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { WeatherMcp } from '../../src/nest/weather/weather.mcp'; import { WeatherModule } from '../../src/nest/weather/weather.module'; +import { WIND_UNAVAILABLE_MESSAGE, WindService } from '../../src/nest/weather/wind.service'; import { createTestMikroOrmModule } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { createTempDb, seedUser, sessionCookie } from './harness'; @@ -49,6 +54,8 @@ describe('Weather e2e (real auth guard + temp SQLite)', () => { const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); nest.useGlobalFilters(new TrekExceptionFilter()); + // Mirror the production APP_PIPE: the wind route's DTO query is coerced as under buildApp(). + nest.useGlobalPipes(new ZodValidationPipe()); await nest.init(); return nest; } @@ -103,4 +110,58 @@ describe('Weather e2e (real auth guard + temp SQLite)', () => { expect(res.status).toBe(200); expect(res.body).toMatchObject({ type: 'forecast' }); }); + + // ── Wind: GET /wind and get_wind_forecast ask the one service alike ─────── + + const WIND = { + status: 'forecast' as const, + date: '2026-07-01', + lat: 38.7, + lng: -9.1, + timezone: 'Europe/Lisbon', + source: 'Open-Meteo' as const, + fetched_at: '2026-06-30T08:00:00.000Z', + horizon_days: 16, + hours: [{ hour: 14, speed_kmh: 22, gusts_kmh: 38, direction_deg: 320 }], + day: { speed_max_kmh: 22, gusts_max_kmh: 38, direction_dominant_deg: 320 }, + }; + const asMcp = { userId: 1 } as McpContext; + + it('WEATHER-E2E-001: the wind route and get_wind_forecast answer the same forecast for the same query', async () => { + const forecast = vi.spyOn(app.get(WindService), 'forecast').mockResolvedValue(WIND); + + const res = await request(server) + .get('/api/weather/wind') + .set('Cookie', sessionCookie(1)) + .query({ lat: '38.7', lng: '-9.1', date: '2026-07-01' }); + expect(res.status).toBe(200); + expect(res.body).toEqual(WIND); + + const tool = await app.get(WeatherMcp).getWindForecast({ lat: 38.7, lng: -9.1, date: '2026-07-01' }, asMcp); + expect(JSON.parse(tool.content[0]?.text ?? '')).toEqual({ wind: WIND }); + expect(forecast.mock.calls).toEqual([ + [{ lat: 38.7, lng: -9.1, date: '2026-07-01' }], + [{ lat: 38.7, lng: -9.1, date: '2026-07-01' }], + ]); + forecast.mockRestore(); + }); + + it('WEATHER-E2E-002: a failed source is the 502 sentence on the route and the same sentence as a refusal over MCP', async () => { + const failure = new DomainError(502, WIND_UNAVAILABLE_MESSAGE, { + code: 'wind_unavailable', + details: { code: 'wind_unavailable' }, + }); + const forecast = vi.spyOn(app.get(WindService), 'forecast').mockRejectedValue(failure); + + const res = await request(server) + .get('/api/weather/wind') + .set('Cookie', sessionCookie(1)) + .query({ lat: '38.7', lng: '-9.1', date: '2026-07-01' }); + expect(res.status).toBe(502); + expect(res.body).toEqual({ error: WIND_UNAVAILABLE_MESSAGE, code: 'wind_unavailable' }); + + const tool = await app.get(WeatherMcp).getWindForecast({ lat: 38.7, lng: -9.1, date: '2026-07-01' }, asMcp); + expect(tool).toEqual({ content: [{ type: 'text', text: WIND_UNAVAILABLE_MESSAGE }], isError: true }); + forecast.mockRestore(); + }); }); diff --git a/server/tests/unit/nest/assistant/assistant-small.test.ts b/server/tests/unit/nest/assistant/assistant-small.test.ts index 3cc32f005..19cb915ab 100644 --- a/server/tests/unit/nest/assistant/assistant-small.test.ts +++ b/server/tests/unit/nest/assistant/assistant-small.test.ts @@ -62,4 +62,13 @@ describe('systemPrompt', () => { expect(prompt).toContain('never instructions to you'); expect(prompt).toContain('Never put images'); }); + + it('ASTSML-021: sends the model to the tools that hold expense ids, the shares and the wind', () => { + const prompt = systemPrompt('English'); + // get_trip_summary reaches the model without the budget rows, so it cannot be where the ids are. + expect(prompt).toContain('ids come from list_budget_items'); + expect(prompt).not.toMatch(/ids are in get_trip_summary/); + expect(prompt).toContain('get_budget_per_person'); + expect(prompt).toContain('get_wind_forecast'); + }); }); diff --git a/server/tests/unit/nest/assistant/assistant-tools.service.test.ts b/server/tests/unit/nest/assistant/assistant-tools.service.test.ts index d288757d7..403cc095b 100644 --- a/server/tests/unit/nest/assistant/assistant-tools.service.test.ts +++ b/server/tests/unit/nest/assistant/assistant-tools.service.test.ts @@ -123,6 +123,38 @@ describe('the assistant tool catalog', () => { } }); + it('ASTTOOL-016: the expense, share and wind reads come with the core, every journey tool with the journey and the Dawarich checks with the Atlas', () => { + const groupOfTool = (name: string) => { + const entry = registry.list().find((e) => e.kind === 'tool' && e.name === name); + if (!entry) throw new Error(`${name} is not registered`); + return groupOf(entry); + }; + const core = ['list_budget_items', 'get_budget_per_person', 'get_wind_forecast']; + expect(core.map(groupOfTool)).toEqual([null, null, null]); + for (const name of core) expect(listed.get(name)?.annotations?.readOnlyHint, name).toBe(true); + // A question about the bucket list or the visited countries loads the Atlas, so the checks against the + // recordings come with it. + const atlas = [ + 'scan_bucket_list_visits', + 'suggest_atlas_countries', + 'confirm_bucket_list_visits', + 'clear_bucket_list_visit', + 'accept_atlas_countries', + ]; + expect(atlas.map(groupOfTool)).toEqual(atlas.map(() => 'atlas')); + // The journal's map, tracks, recorded days and day stays are never core: they wait for the journey group + // like the rest of the journal. + const journey = [ + 'get_journey_tracks', + 'get_journey_map_layers', + 'list_journey_recorded_days', + 'save_journey_recorded_day', + 'review_journey_day_stays', + 'take_over_journey_day_stays', + ]; + expect(journey.map(groupOfTool)).toEqual(journey.map(() => 'journey')); + }); + it('ASTTOOL-017: the hike tools come with the tours, the proposal runs at once and the writes are cards of a tour', () => { const hikeTools = ['propose_hike', 'create_hike', 'update_tour', 'import_tour_gpx']; const entries = hikeTools.map((name) => registry.list().find((e) => e.kind === 'tool' && e.name === name)); diff --git a/server/tests/unit/nest/budget-items.mcp.test.ts b/server/tests/unit/nest/budget-items.mcp.test.ts new file mode 100644 index 000000000..59657575c --- /dev/null +++ b/server/tests/unit/nest/budget-items.mcp.test.ts @@ -0,0 +1,379 @@ +/** + * BudgetItemsMcp: the expense list and the per-person totals over MCP, on the + * same service calls and the same trip access as GET /api/trips/:tripId/budget + * and GET …/budget/summary/per-person. The suite takes its subject from the + * container over a snapshot test db; only the exchange rates are stubbed, so + * nothing reaches the network. The addon gate is the registry's. + */ +import { ADDON_IDS } from '../../../src/addons'; +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import type { McpContext } from '../../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; +import { toolOutputForProvider } from '../../../src/nest/assistant/assistant-redact'; +import { BudgetItemsMcp } from '../../../src/nest/budget/budget-items.mcp'; +import { BudgetModule } from '../../../src/nest/budget/budget.module'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; +import type { BudgetItem } from '../../../src/types'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { addBudgetItemMember, addBudgetItemPayer, makeBudgetItem } from '../../helpers/factories/budget'; +import { makePlace } from '../../helpers/factories/places'; +import { makeReservation } from '../../helpers/factories/reservations'; +import { insertRows } from '../../helpers/factories/rows'; +import { addTripMember, makeTrip } from '../../helpers/factories/trips'; +import { makeUser } from '../../helpers/factories/users'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let orm: TestOrm; +let mcp: BudgetItemsMcp; +/** Today's quote against the trip currency; null is a server that cannot quote. */ +const getRates = vi.fn<(base: string) => Promise | null>>(); + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [BudgetModule], + overrides: [{ provide: ExchangeRatesService, useValue: { getRates } }], + }); + mcp = t.get(BudgetItemsMcp); + orm = await createTestOrm(db); +}); +beforeEach(() => { + resetTestDb(db); + orm.clear(); + vi.restoreAllMocks(); + getRates.mockReset(); + getRates.mockResolvedValue(null); +}); +afterAll(async () => { + await orm.close(); + await t.close(); + db.close(); +}); + +const ctx = (userId: number) => ({ userId }) as McpContext; + +function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) { + const text = result.content[0]?.text ?? ''; + return result.isError ? { error: text } : JSON.parse(text); +} + +/** + * A euro trip of two: a dinner on a place the two split evenly, paid by the + * owner, of which the guest paid their share back; and a museum booking in + * dollars at a frozen rate, split unevenly and paid by the guest. A third user + * cannot open the trip. + */ +async function fixture() { + const { user: owner } = await makeUser(orm, { username: 'ana' }); + const { user: guest } = await makeUser(orm, { username: 'ben' }); + const { user: outsider } = await makeUser(orm); + const trip = await makeTrip(orm, owner.id, { title: 'Lisbon', currency: 'EUR' }); + await addTripMember(orm, trip.id, guest.id); + const place = await makePlace(orm, trip.id, { name: 'Time Out Market' }); + const booking = await makeReservation(orm, trip.id, { title: 'Gulbenkian', type: 'activity' }); + const dinner = await makeBudgetItem(orm, trip.id, { + name: 'Dinner', + category: 'food', + total_price: 90, + place: place.id, + expense_date: '2026-05-02', + note: 'Window table,\n tip included', + sort_order: 1, + }); + await addBudgetItemMember(orm, dinner.id, owner.id, { paid: 1 }); + await addBudgetItemMember(orm, dinner.id, guest.id, { paid: 1 }); + await addBudgetItemPayer(orm, dinner.id, owner.id, 90); + // 1.25 dollars to the euro, frozen when it was booked: 50 USD is 40 EUR. + const museum = await makeBudgetItem(orm, trip.id, { + name: 'Museum', + category: 'activities', + total_price: 50, + currency: 'USD', + exchange_rate: 1.25, + reservation: booking.id, + sort_order: 2, + }); + await addBudgetItemMember(orm, museum.id, owner.id, { amount: 30 }); + await addBudgetItemMember(orm, museum.id, guest.id, { amount: 20, paid: 1 }); + await addBudgetItemPayer(orm, museum.id, guest.id, 50); + return { owner, guest, outsider, trip, place, booking, dinner, museum }; +} + +function toolOptions(method: string) { + const entry = getEntry(BudgetItemsMcp as unknown as ClassRef, method); + if (entry?.kind !== 'tool') throw new Error(`${method} is no tool`); + return entry.options; +} + +describe('BudgetItemsMcp surface', () => { + it.each([ + ['listBudgetItems', 'list_budget_items'], + ['getBudgetPerPerson', 'get_budget_per_person'], + ])( + 'BGI-MCP-001: %s is the read-only %s, a budget read under the budget addon, broadcasting nothing', + async (method, name) => { + const options = toolOptions(method); + expect(options.name).toBe(name); + expect(options.access).toEqual({ group: 'budget', mode: 'read' }); + expect(options.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(false); + expect(await options.when?.(ctx(1), mcp)).toBe(false); + expect(enabled).toHaveBeenCalledWith(ADDON_IDS.BUDGET); + enabled.mockResolvedValue(true); + expect(await options.when?.(ctx(1), mcp)).toBe(true); + }, + ); +}); + +describe('list_budget_items', () => { + it('BGI-MCP-010: answers each expense slim, with its links, payers and split, and the totals in the trip currency', async () => { + const f = await fixture(); + + const answer = read(await mcp.listBudgetItems({ tripId: f.trip.id }, ctx(f.guest.id))); + + expect(answer).toEqual({ + currency: 'EUR', + total: 130, + by_category: { food: 90, activities: 40 }, + unconverted_item_ids: [], + item_count: 2, + offset: 0, + next_offset: null, + items: [ + { + id: f.dinner.id, + name: 'Dinner', + category: 'food', + total_price: 90, + currency: 'EUR', + expense_date: '2026-05-02', + note: 'Window table, tip included', + place_id: f.place.id, + reservation_id: null, + payers: [{ user_id: f.owner.id, amount: 90 }], + members: [ + { user_id: f.owner.id, amount: null, paid: true }, + { user_id: f.guest.id, amount: null, paid: true }, + ], + }, + { + id: f.museum.id, + name: 'Museum', + category: 'activities', + total_price: 50, + currency: 'USD', + expense_date: null, + note: null, + place_id: null, + reservation_id: f.booking.id, + payers: [{ user_id: f.guest.id, amount: 50 }], + members: [ + { user_id: f.owner.id, amount: 30, paid: false }, + { user_id: f.guest.id, amount: 20, paid: true }, + ], + }, + ], + people: [ + { user_id: f.owner.id, username: 'ana' }, + { user_id: f.guest.id, username: 'ben' }, + ], + }); + // Both rows carry a rate of their own, so the totals never asked for today's. + expect(getRates).not.toHaveBeenCalled(); + // No avatars, receipts or itemised tickets: the REST row's bulk stays behind. + expect(JSON.stringify(answer)).not.toMatch(/avatar|receipts|ticket_json/); + }); + + it('BGI-MCP-011: pages through a long budget while the totals keep covering all of it', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { currency: 'eur' }); + const ids = await insertRows( + orm, + BudgetItems, + [10, 20, 30].map((price, i) => ({ trip: trip.id, name: `Taxi ${i + 1}`, total_price: price, sort_order: i })), + ); + + const first = read(await mcp.listBudgetItems({ tripId: trip.id, limit: 2 }, ctx(user.id))); + expect(first).toMatchObject({ currency: 'EUR', total: 60, item_count: 3, offset: 0, next_offset: 2 }); + expect(first.items.map((item: { id: number }) => item.id)).toEqual(ids.slice(0, 2)); + // A row without a currency is in the trip's, and says so. + expect(first.items[0]).toMatchObject({ currency: 'EUR', payers: [], members: [] }); + expect(first.people).toEqual([]); + + const last = read(await mcp.listBudgetItems({ tripId: trip.id, offset: 2, limit: 2 }, ctx(user.id))); + expect(last).toMatchObject({ total: 60, item_count: 3, offset: 2, next_offset: null }); + expect(last.items.map((item: { id: number }) => item.id)).toEqual([ids[2]]); + + const beyond = read(await mcp.listBudgetItems({ tripId: trip.id, offset: 5 }, ctx(user.id))); + expect(beyond).toMatchObject({ item_count: 3, offset: 5, next_offset: null, items: [] }); + }); + + it('BGI-MCP-012: a default page holds 50 expenses and points at the next one', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id); + await insertRows( + orm, + BudgetItems, + Array.from({ length: 51 }, (_, i) => ({ trip: trip.id, name: `Coffee ${i}`, total_price: 2, sort_order: i })), + ); + + const page = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id))); + expect(page.items).toHaveLength(50); + expect(page).toMatchObject({ item_count: 51, next_offset: 50, total: 102 }); + }); + + it('BGI-MCP-013: a foreign expense no rate converts is named and left out of every total', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { currency: 'EUR' }); + await makeBudgetItem(orm, trip.id, { name: 'Hotel', category: 'accommodation', total_price: 200, sort_order: 1 }); + // Rate 1 on a foreign row: never frozen, and the server has no quote today. + const pho = await makeBudgetItem(orm, trip.id, { + name: 'Pho', + category: 'food', + total_price: 120_000, + currency: 'VND', + exchange_rate: 1, + sort_order: 2, + }); + + const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id))); + expect(answer).toMatchObject({ total: 200, by_category: { accommodation: 200 }, unconverted_item_ids: [pho.id] }); + expect(getRates).toHaveBeenCalledWith('EUR'); + }); + + it('BGI-MCP-014: someone who cannot open the trip gets the trip refusal and no row', async () => { + const f = await fixture(); + const list = vi.spyOn(t.get(BudgetService), 'listBudgetItems'); + + const refused = read(await mcp.listBudgetItems({ tripId: f.trip.id }, ctx(f.outsider.id))); + + expect(refused).toEqual({ error: 'Trip not found or access denied.' }); + expect(list).not.toHaveBeenCalled(); + }); + + it('BGI-MCP-015: an expense handed over without its split or payers reads as shared by nobody', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id); + const bare: BudgetItem = { id: 7, trip_id: trip.id, category: 'other', name: 'Tip', total_price: 5, sort_order: 0 }; + vi.spyOn(t.get(BudgetService), 'listBudgetItems').mockResolvedValue([bare]); + + const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id))); + + expect(answer.items).toEqual([ + expect.objectContaining({ id: 7, place_id: null, reservation_id: null, payers: [], members: [] }), + ]); + expect(answer.people).toEqual([]); + }); + + it('BGI-MCP-016: a page of long expenses ends before the assistant cuts the answer, and next_offset starts at the first one it left out', async () => { + const { user } = await makeUser(orm, { username: 'organiser' }); + const trip = await makeTrip(orm, user.id, { currency: 'EUR' }); + const others = await Promise.all( + Array.from({ length: 4 }, (_, i) => makeUser(orm, { username: `traveller-${i}` })), + ); + const travellers = [user.id, ...others.map((other) => other.user.id)]; + const name = (i: number) => `Dinner ${i} ${'at the long table by the harbour, '.repeat(6)}`; + const ids = await insertRows( + orm, + BudgetItems, + Array.from({ length: 100 }, (_, i) => ({ trip: trip.id, name: name(i), total_price: 50, sort_order: i })), + ); + await insertRows( + orm, + BudgetItemMembers, + ids.flatMap((id) => travellers.map((traveller) => ({ budgetItem: id, user: traveller, amount: 10 }))), + ); + + /** Every page from `offset` on, each checked as the provider would get it. */ + const pagesFrom = async (offset: number): Promise => { + const answer = read(await mcp.listBudgetItems({ tripId: trip.id, offset, limit: 100 }, ctx(user.id))); + // What the provider gets is the page whole: nothing cut, nothing the model would never see. + expect(toolOutputForProvider('list_budget_items', answer)).toEqual(answer); + const page: number[] = answer.items.map((item: { id: number }) => item.id); + if (answer.next_offset === null) return [page]; + expect(answer.next_offset).toBe(offset + page.length); + return [page, ...(await pagesFrom(answer.next_offset))]; + }; + const pages = await pagesFrom(0); + + // Asked for all hundred at once, the expenses came in more than one page and none was skipped. + expect(pages.length).toBeGreaterThan(1); + expect(pages.flat()).toEqual(ids); + }); + + it('BGI-MCP-017: one expense longer than a page on its own still makes a page, so the list never stalls', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id); + const ids = await insertRows(orm, BudgetItems, [ + { trip: trip.id, name: 'Charter '.repeat(5_000), total_price: 900, sort_order: 0 }, + { trip: trip.id, name: 'Fuel', total_price: 80, sort_order: 1 }, + ]); + + const first = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id))); + expect(first.items.map((item: { id: number }) => item.id)).toEqual([ids[0]]); + expect(first.next_offset).toBe(1); + const second = read(await mcp.listBudgetItems({ tripId: trip.id, offset: 1 }, ctx(user.id))); + expect(second).toMatchObject({ next_offset: null, items: [expect.objectContaining({ id: ids[1] })] }); + }); + + it('BGI-MCP-018: a long note keeps its start on one line, and a trip without a currency reads in euros', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { currency: null }); + await makeBudgetItem(orm, trip.id, { name: 'Ferry', total_price: 30, note: `Deck seats\n${'x'.repeat(400)}` }); + + const answer = read(await mcp.listBudgetItems({ tripId: trip.id }, ctx(user.id))); + + expect(answer).toMatchObject({ currency: 'EUR', total: 30, items: [{ currency: 'EUR' }] }); + const note: string = answer.items[0].note; + expect(note).toHaveLength(200); + expect(note.startsWith('Deck seats xxx')).toBe(true); + expect(note.endsWith('…')).toBe(true); + }); +}); + +describe('get_budget_per_person', () => { + it('BGI-MCP-020: answers the shares in the trip currency, as the REST route does', async () => { + const f = await fixture(); + + const answer = read(await mcp.getBudgetPerPerson({ tripId: f.trip.id }, ctx(f.owner.id))); + + expect(answer).toEqual({ summary: await t.get(BudgetService).perPersonSummary(f.trip.id) }); + // Ana: 45 of the dinner, paid, and 30 USD of the museum at 1.25, not paid. + // Ben: 45 of the dinner, paid, and 20 USD of the museum at 1.25, paid. + expect(answer.summary).toEqual([ + expect.objectContaining({ + user_id: f.owner.id, + total_assigned: 69, + total_paid: 45, + items_count: 2, + currency: 'EUR', + }), + expect.objectContaining({ + user_id: f.guest.id, + total_assigned: 61, + total_paid: 61, + items_count: 2, + currency: 'EUR', + }), + ]); + }); + + it('BGI-MCP-021: someone who cannot open the trip gets the trip refusal', async () => { + const f = await fixture(); + const summary = vi.spyOn(t.get(BudgetService), 'perPersonSummary'); + + expect(read(await mcp.getBudgetPerPerson({ tripId: f.trip.id }, ctx(f.outsider.id)))).toEqual({ + error: 'Trip not found or access denied.', + }); + expect(summary).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/dawarich-atlas.mcp.test.ts b/server/tests/unit/nest/dawarich-atlas.mcp.test.ts new file mode 100644 index 000000000..466f3dab3 --- /dev/null +++ b/server/tests/unit/nest/dawarich-atlas.mcp.test.ts @@ -0,0 +1,382 @@ +/** + * DawarichAtlasMcp (DAWARICH-ATLAS-MCP-001..014): the bucket list and the + * Atlas checked against the Dawarich recordings over MCP, the twins of + * /api/integrations/dawarich/bucket-list/* and /atlas/*. + * + * The subject comes out of the DawarichModule container over a snapshot test + * db, so the wishes, the ticks and the Atlas marks are real rows and the + * scoping to the caller is the repositories' own. Only the two seams that + * would leave the process are stubbed: DawarichService hands out the stored + * credentials (their encryption is its own suite's business), and + * DawarichClient answers for the instance, so nothing reaches the network. + */ +import { ADDON_IDS } from '../../../src/addons'; +import { BucketList } from '../../../src/db/entities/BucketList.entity'; +import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity'; +import { trekMcpErrorMapper } from '../../../src/mcp/nest-mcp-policy'; +import { errorResult, type McpContext, type McpTextResult, type ToolOptions } from '../../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; +import { toolOutputForProvider } from '../../../src/nest/assistant/assistant-redact'; +import { DomainError } from '../../../src/nest/common/domain-error'; +import { DawarichAtlasMcp } from '../../../src/nest/integrations/dawarich-atlas.mcp'; +import { DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service'; +import { DawarichClient, DawarichError } from '../../../src/nest/integrations/dawarich.client'; +import { DawarichModule } from '../../../src/nest/integrations/dawarich.module'; +import { DawarichService } from '../../../src/nest/integrations/dawarich.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { makeBucketListItem, markCountryVisited } from '../../helpers/factories/atlas'; +import { countRows, findRow } from '../../helpers/factories/rows'; +import { makeUser } from '../../helpers/factories/users'; +import { callGatedTool } from '../../helpers/mcp-gate'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z, type ZodRawShape } from 'zod'; + +const CREDS = { baseUrl: 'https://dawarich.example.com', apiKey: 'test-key', allowInsecureTls: false }; + +const dawarichStub = { getCredentials: vi.fn() }; +const clientStub = { findVisitsNear: vi.fn(), listVisitedCities: vi.fn() }; + +const db = createSnapshotTestDb(); +let t: TestModule; +let orm: TestOrm; +let mcp: DawarichAtlasMcp; + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [DawarichModule], + overrides: [ + { provide: DawarichService, useValue: dawarichStub }, + { provide: DawarichClient, useValue: clientStub }, + ], + }); + mcp = t.get(DawarichAtlasMcp); + orm = await createTestOrm(db); +}); +beforeEach(() => { + resetTestDb(db); + orm.clear(); + vi.restoreAllMocks(); + dawarichStub.getCredentials.mockReset().mockResolvedValue(CREDS); + clientStub.findVisitsNear.mockReset().mockResolvedValue([]); + clientStub.listVisitedCities.mockReset().mockResolvedValue([]); +}); +afterAll(async () => { + await orm.close(); + await t.close(); + db.close(); +}); + +const ctx = (userId: number) => ({ userId, scopes: null, isStaticToken: false }) as McpContext; + +function read(result: McpTextResult) { + const text = result.content[0]?.text ?? ''; + return result.isError ? { error: text } : JSON.parse(text); +} + +function toolOptions(method: string): ToolOptions { + const entry = getEntry(DawarichAtlasMcp as unknown as ClassRef, method); + if (entry?.kind !== 'tool') throw new Error(`${method} is no tool`); + return entry.options; +} + +function inputSchema(method: string) { + return z.object(toolOptions(method).inputSchema as ZodRawShape); +} + +/** A stay of the given length, the given distance off a wish, starting at `at`. */ +function stay(at: string, minutes: number, distance: number) { + return { + timestamp: 1_757_000_000, + distance_meters: distance, + points_count: 30, + visit_details: { start_time: at, duration_minutes: minutes }, + }; +} + +/** The owner's wishes: two with coordinates, one without; and a stranger's wish next to them. */ +async function fixture() { + const { user: owner } = await makeUser(orm); + const { user: stranger } = await makeUser(orm); + const alhambra = await makeBucketListItem(orm, owner.id, { name: 'Alhambra', lat: 37.176, lng: -3.588 }); + const petra = await makeBucketListItem(orm, owner.id, { name: 'Petra', lat: 30.328, lng: 35.444 }); + await makeBucketListItem(orm, owner.id, { name: 'Somewhere warm' }); + const theirs = await makeBucketListItem(orm, stranger.id, { name: 'Kyoto', lat: 35.011, lng: 135.768 }); + return { owner, stranger, alhambra, petra, theirs }; +} + +describe('DawarichAtlasMcp surface', () => { + it('DAWARICH-ATLAS-MCP-001: exactly the five bucket-list and Atlas tools, none asking for a credential', () => { + const methods = Object.getOwnPropertyNames(DawarichAtlasMcp.prototype).filter( + (name) => getEntry(DawarichAtlasMcp as unknown as ClassRef, name) !== undefined, + ); + expect(methods.map((method) => toolOptions(method).name).sort()).toEqual([ + 'accept_atlas_countries', + 'clear_bucket_list_visit', + 'confirm_bucket_list_visits', + 'scan_bucket_list_visits', + 'suggest_atlas_countries', + ]); + for (const method of methods) { + expect(Object.keys(toolOptions(method).inputSchema ?? {}).filter((key) => /url|key|token/i.test(key))).toEqual( + [], + ); + } + }); + + it.each([ + ['scanBucketList', 'read', true, true, true], + ['suggestAtlasCountries', 'read', true, true, true], + ['confirmBucketVisits', 'write', false, true, false], + ['clearBucketVisit', 'write', false, true, false], + ['acceptAtlasCountries', 'write', false, true, false], + ])( + 'DAWARICH-ATLAS-MCP-002: %s claims atlas:%s and advertises readOnly=%s idempotent=%s openWorld=%s', + (method, mode, readOnly, idempotent, openWorld) => { + const options = toolOptions(method); + expect(options.access).toEqual({ group: 'atlas', mode }); + expect(options.annotations).toMatchObject({ + readOnlyHint: readOnly, + idempotentHint: idempotent, + openWorldHint: openWorld, + }); + }, + ); + + it('DAWARICH-ATLAS-MCP-003: every tool rides the Dawarich addon, and clearing a visit is announced as a removal', async () => { + const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(false); + const methods = ['scanBucketList', 'confirmBucketVisits', 'clearBucketVisit', 'acceptAtlasCountries']; + expect(await Promise.all(methods.map((method) => toolOptions(method).when?.(ctx(1), mcp)))).toEqual( + methods.map(() => false), + ); + expect(enabled).toHaveBeenCalledWith(ADDON_IDS.DAWARICH); + enabled.mockResolvedValue(true); + expect(await toolOptions('suggestAtlasCountries').when?.(ctx(1), mcp)).toBe(true); + expect(toolOptions('clearBucketVisit').annotations).toMatchObject({ destructiveHint: true }); + }); + + it('DAWARICH-ATLAS-MCP-004: the inputs are the route bodies: 1 to 200 wish ids, one wish, 1 to 300 two-letter codes, both window ends', () => { + expect(inputSchema('scanBucketList').safeParse({}).success).toBe(true); + const confirm = inputSchema('confirmBucketVisits'); + expect(confirm.safeParse({ itemIds: [1, 2], visitedAt: '2024-04-02' }).success).toBe(true); + expect(confirm.safeParse({ itemIds: [] }).success).toBe(false); + expect(confirm.safeParse({ itemIds: Array.from({ length: 201 }, (_, i) => i + 1) }).success).toBe(false); + expect(confirm.safeParse({ itemIds: [0] }).success).toBe(false); + expect(inputSchema('clearBucketVisit').safeParse({ itemId: 3 }).success).toBe(true); + expect(inputSchema('clearBucketVisit').safeParse({ itemId: -3 }).success).toBe(false); + const accept = inputSchema('acceptAtlasCountries'); + expect(accept.safeParse({ countryCodes: ['PT', 'es'] }).success).toBe(true); + expect(accept.safeParse({ countryCodes: ['PRT'] }).success).toBe(false); + expect(accept.safeParse({ countryCodes: [] }).success).toBe(false); + const window = inputSchema('suggestAtlasCountries'); + expect(window.safeParse({ from: '2026-01-01', to: '2026-02-01' }).success).toBe(true); + expect(window.safeParse({ from: '2026-01-01' }).success).toBe(false); + }); + + it('DAWARICH-ATLAS-MCP-005: the two reads pass the demo gate, the three writes are refused for the demo account and write nothing', async () => { + const f = await fixture(); + const isDemoUser = vi.fn().mockResolvedValue(true); + + const call = ([method, args]: [string, unknown]) => callGatedTool(mcp, method, args, ctx(f.owner.id), isDemoUser); + + const reads: [string, unknown][] = [ + ['scanBucketList', {}], + ['suggestAtlasCountries', { from: '2026-01-01', to: '2026-02-01' }], + ]; + const answered = (await Promise.all(reads.map(call))) as McpTextResult[]; + expect(answered.map((answer) => answer.isError)).toEqual([undefined, undefined]); + expect(isDemoUser).not.toHaveBeenCalled(); + + const writes: [string, unknown][] = [ + ['confirmBucketVisits', { itemIds: [f.alhambra.id] }], + ['clearBucketVisit', { itemId: f.alhambra.id }], + ['acceptAtlasCountries', { countryCodes: ['PT'] }], + ]; + expect(await Promise.all(writes.map(call))).toEqual( + writes.map(() => errorResult('Write operations are disabled in demo mode.')), + ); + expect((await findRow(orm, BucketList, { id: f.alhambra.id }))?.visited_at).toBeNull(); + expect(await countRows(orm, VisitedCountries, { user: f.owner.id })).toBe(0); + }); +}); + +describe('scan_bucket_list_visits', () => { + it("DAWARICH-ATLAS-MCP-006: asks the recordings about the caller's own wishes and ticks nothing off", async () => { + const f = await fixture(); + clientStub.findVisitsNear.mockImplementation(async (_creds: unknown, lat: number) => + lat === 37.176 ? [stay('2024-04-02T10:00:00+02:00', 95, 40)] : [], + ); + + const answer = read(await mcp.scanBucketList({}, ctx(f.owner.id))); + + // Newest wish first, as the Atlas lists them. + expect(answer).toMatchObject({ + matches: [ + { itemId: f.petra.id, name: 'Petra', match: null, alreadyVisited: false }, + { + itemId: f.alhambra.id, + name: 'Alhambra', + match: { at: '2024-04-02T10:00:00+02:00', minutes: 95, distanceMeters: 40, points: 30 }, + alreadyVisited: false, + }, + ], + skippedWithoutCoordinates: 1, + truncated: false, + }); + // The stranger's wish is never asked about. + expect(clientStub.findVisitsNear.mock.calls.map((call) => call[1])).toEqual([30.328, 37.176]); + expect(dawarichStub.getCredentials).toHaveBeenCalledWith(f.owner.id); + expect((await findRow(orm, BucketList, { id: f.alhambra.id }))?.visited_at).toBeNull(); + }); + + it('DAWARICH-ATLAS-MCP-007: without a connection the scan is the route refusal, and nothing is asked', async () => { + const f = await fixture(); + dawarichStub.getCredentials.mockResolvedValue(null); + + expect(await mcp.scanBucketList({}, ctx(f.owner.id))).toEqual(errorResult('Dawarich is not connected')); + expect(clientStub.findVisitsNear).not.toHaveBeenCalled(); + }); + + it('DAWARICH-ATLAS-MCP-008: an instance that does not answer ends the scan after one lookup, in TREK words', async () => { + const f = await fixture(); + clientStub.findVisitsNear.mockRejectedValue( + new DawarichError('unreachable', 'Could not reach Dawarich', undefined, 'connect ETIMEDOUT 10.0.0.5:443'), + ); + + // The upstream detail stays behind, as it does on the route. + expect(await mcp.scanBucketList({}, ctx(f.owner.id))).toEqual(errorResult('Could not reach Dawarich')); + expect(clientStub.findVisitsNear).toHaveBeenCalledTimes(1); + }); +}); + +describe('suggest_atlas_countries', () => { + it('DAWARICH-ATLAS-MCP-009: reads the window as instants and marks the countries the Atlas has already', async () => { + const { user } = await makeUser(orm); + await markCountryVisited(orm, user.id, 'ES'); + clientStub.listVisitedCities.mockResolvedValue([ + { country: 'Portugal', cities: [{ city: 'Lisbon', stayed_for: 300, timestamp: 1_767_225_600 }] }, + { country: 'Spain', cities: [{ city: 'Seville', stayed_for: 90 }] }, + { country: 'Atlantis', cities: [] }, + ]); + + const answer = read( + await mcp.suggestAtlasCountries({ from: '2026-01-01', to: '2026-02-01T00:00:00Z' }, ctx(user.id)), + ); + + expect(clientStub.listVisitedCities).toHaveBeenCalledWith( + CREDS, + new Date('2026-01-01T00:00:00.000Z'), + new Date('2026-02-01T00:00:00.000Z'), + ); + expect(answer).toMatchObject({ + countries: [ + { + countryCode: 'PT', + sourceName: 'Portugal', + cities: [{ name: 'Lisbon', minutes: 300, lastSeenAt: '2026-01-01T00:00:00.000Z' }], + alreadyVisited: false, + }, + { countryCode: 'ES', sourceName: 'Spain', alreadyVisited: true }, + ], + unresolved: ['Atlantis'], + }); + }); + + it('DAWARICH-ATLAS-MCP-010: the assistant hands the provider the cities and minutes, never when the user was where', async () => { + const { user } = await makeUser(orm); + clientStub.listVisitedCities.mockResolvedValue([ + { country: 'Portugal', cities: [{ city: 'Lisbon', stayed_for: 300, timestamp: 1_767_225_600 }] }, + ]); + const answer = read(await mcp.suggestAtlasCountries({ from: '2026-01-01', to: '2026-02-01' }, ctx(user.id))); + + const sent = toolOutputForProvider('suggest_atlas_countries', answer) as typeof answer; + + expect(sent.countries).toEqual([ + { countryCode: 'PT', sourceName: 'Portugal', cities: [{ name: 'Lisbon', minutes: 300 }], alreadyVisited: false }, + ]); + expect(sent).toMatchObject({ unresolved: [], fetchedAt: answer.fetchedAt }); + expect(JSON.stringify(sent)).not.toContain('lastSeenAt'); + }); + + it('DAWARICH-ATLAS-MCP-011: an instance that fails is a refusal in TREK words', async () => { + const { user } = await makeUser(orm); + clientStub.listVisitedCities.mockRejectedValue( + new DawarichError('server_error', 'Dawarich answered HTTP 500', 500), + ); + + expect(await mcp.suggestAtlasCountries({ from: '2026-01-01', to: '2026-02-01' }, ctx(user.id))).toEqual( + errorResult('Dawarich answered HTTP 500'), + ); + }); + + it.each([ + [{ from: '2026-02-01', to: '2026-01-01' }, 'to must be after from'], + [{ from: 'soon', to: '2026-01-01' }, 'from and to are required ISO timestamps'], + [{ from: '2024-01-01', to: '2026-01-01' }, 'Window must be 400 days or less'], + ])( + 'DAWARICH-ATLAS-MCP-012: the window %j answers the route 400 sentence (%s) as the refusal, before the instance is asked', + async (window, message) => { + const { user } = await makeUser(orm); + let thrown: unknown; + try { + await mcp.suggestAtlasCountries(window, ctx(user.id)); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(DomainError); + expect(thrown).toMatchObject({ status: 400, publicMessage: message }); + // What the registry answers the call with. + expect(trekMcpErrorMapper(thrown)).toEqual(errorResult(message)); + expect(clientStub.listVisitedCities).not.toHaveBeenCalled(); + }, + ); +}); + +describe('the writes', () => { + it("DAWARICH-ATLAS-MCP-013: ticks off and clears only the caller's own wishes, as the routes do", async () => { + const f = await fixture(); + + const confirmed = read( + await mcp.confirmBucketVisits( + { itemIds: [f.alhambra.id, f.theirs.id], visitedAt: '2024-04-02T10:00:00Z' }, + ctx(f.owner.id), + ), + ); + expect(confirmed).toEqual({ updated: 1 }); + expect(await findRow(orm, BucketList, { id: f.alhambra.id })).toMatchObject({ + visited_at: '2024-04-02T10:00:00Z', + visited_source: 'dawarich', + }); + expect((await findRow(orm, BucketList, { id: f.theirs.id }))?.visited_at).toBeNull(); + // Ticked off already: it keeps its date. + expect(read(await mcp.confirmBucketVisits({ itemIds: [f.alhambra.id] }, ctx(f.owner.id)))).toEqual({ updated: 0 }); + + expect(await mcp.clearBucketVisit({ itemId: f.alhambra.id }, ctx(f.stranger.id))).toEqual( + errorResult('Bucket-list entry not found'), + ); + expect((await findRow(orm, BucketList, { id: f.alhambra.id }))?.visited_at).toBe('2024-04-02T10:00:00Z'); + expect(read(await mcp.clearBucketVisit({ itemId: f.alhambra.id }, ctx(f.owner.id)))).toEqual({ success: true }); + expect(await findRow(orm, BucketList, { id: f.alhambra.id })).toMatchObject({ + visited_at: null, + visited_source: null, + }); + }); + + it('DAWARICH-ATLAS-MCP-014: marks confirmed countries once with their source, and a failure that is no refusal propagates', async () => { + const { user } = await makeUser(orm); + + expect(read(await mcp.acceptAtlasCountries({ countryCodes: ['PT', 'es'] }, ctx(user.id)))).toEqual({ marked: 2 }); + expect(read(await mcp.acceptAtlasCountries({ countryCodes: ['ES'] }, ctx(user.id)))).toEqual({ marked: 0 }); + expect(await countRows(orm, VisitedCountries, { user: user.id, source: 'dawarich' })).toBe(2); + + vi.spyOn(t.get(DawarichSuggestionsService), 'acceptAtlasCountries').mockRejectedValue( + new Error('SQLITE_BUSY: database is locked'), + ); + await expect(mcp.acceptAtlasCountries({ countryCodes: ['FR'] }, ctx(user.id))).rejects.toThrow('SQLITE_BUSY'); + }); +}); diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index e2bff347f..67783e50a 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for DawarichSuggestionsService: DAWARICH-SUG-001..064. + * Unit tests for DawarichSuggestionsService: DAWARICH-SUG-001..069. * * This is the only service in the Dawarich domain that writes into TREK proper, * and every write it makes crosses an ownership line: a suggestion belongs to @@ -25,7 +25,7 @@ import type { UsersRepository } from '../../../src/db/repositories/Users.reposit import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { AcceptError, DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service'; -import type { DawarichClient } from '../../../src/nest/integrations/dawarich.client'; +import { DawarichError, type DawarichClient } from '../../../src/nest/integrations/dawarich.client'; import type { DawarichService } from '../../../src/nest/integrations/dawarich.service'; import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import type { JourneyEntryStaysService } from '../../../src/nest/journey/journey-entry-stays.service'; @@ -1139,6 +1139,39 @@ describe('DawarichSuggestionsService — scanning for stays', () => { expect(scan.matches).toHaveLength(1); expect(scan.matches[0].match).toBeNull(); }); + + it.each(['unreachable', 'unauthorized', 'forbidden', 'rate_limited'] as const)( + 'DAWARICH-SUG-068: an instance failing with %s ends the scan at the first wish instead of reporting every wish as missed', + async (code) => { + const { user } = createUser(testDb); + await Promise.all(['Hallstatt', 'Kyoto', 'Petra'].map((name) => seedBucketItem(user.id, { name }))); + dawarichStub.getCredentials.mockReturnValue(CREDS); + const failure = new DawarichError(code, 'Could not reach Dawarich'); + clientStub.findVisitsNear.mockReset().mockRejectedValue(failure); + + await expect(svc.scanBucketList(user.id)).rejects.toBe(failure); + // A hanging instance costs one timeout, not one per wish. + expect(clientStub.findVisitsNear).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['server_error', 'not_found', 'invalid_response'] as const)( + 'DAWARICH-SUG-069: a lookup answered with %s is that one wish missed, and the scan goes on', + async (code) => { + const { user } = createUser(testDb); + await Promise.all(['Hallstatt', 'Kyoto'].map((name) => seedBucketItem(user.id, { name }))); + dawarichStub.getCredentials.mockReturnValue(CREDS); + clientStub.findVisitsNear + .mockReset() + .mockRejectedValueOnce(new DawarichError(code, 'Dawarich answered HTTP 500')) + .mockResolvedValueOnce([]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches.map((m) => m.match)).toEqual([null, null]); + expect(clientStub.findVisitsNear).toHaveBeenCalledTimes(2); + }, + ); }); // ── Atlas ──────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/dawarich.helpers.test.ts b/server/tests/unit/nest/dawarich.helpers.test.ts index cca9d40f6..17dd79bad 100644 --- a/server/tests/unit/nest/dawarich.helpers.test.ts +++ b/server/tests/unit/nest/dawarich.helpers.test.ts @@ -27,6 +27,7 @@ import { minutesBetween, normalizeVisit, offsetMinutesOf, + parseTimeWindow, simplify, splitAtGaps, splitWindow, @@ -1327,3 +1328,25 @@ describe('visited cities over a long range', () => { expect(again[0].cities[0]).toEqual({ city: 'Gedser', points: 10, stayed_for: undefined, timestamp: undefined }); }); }); + +describe('parseTimeWindow', () => { + it('DAWARICH-SPAN-001: reads both ends as instants, a bare date as its midnight UTC, up to 400 days apart', () => { + expect(parseTimeWindow('2025-01-01', '2026-02-05T00:00:00Z')).toEqual({ + from: new Date('2025-01-01T00:00:00.000Z'), + to: new Date('2026-02-05T00:00:00.000Z'), + }); + }); + + it.each<[string | undefined, string | undefined, string]>([ + [undefined, '2026-01-01', 'from and to are required ISO timestamps'], + ['2026-01-01', '', 'from and to are required ISO timestamps'], + ['2026-01-01', 'later', 'from and to are required ISO timestamps'], + ['2026-01-01', '2026-01-01', 'to must be after from'], + ['2025-01-01', '2026-02-06', 'Window must be 400 days or less'], + ])('DAWARICH-SPAN-002: from %j to %j is a 400 both REST and MCP answer with "%s"', (from, to, message) => { + // A DomainError: the route writes it as its { error } body, the MCP registry as the refusal text. + expect(() => parseTimeWindow(from, to)).toThrow( + expect.objectContaining({ name: 'DomainError', status: 400, publicMessage: message }), + ); + }); +}); diff --git a/server/tests/unit/nest/journey-tracks.mcp.test.ts b/server/tests/unit/nest/journey-tracks.mcp.test.ts new file mode 100644 index 000000000..a541fc3bd --- /dev/null +++ b/server/tests/unit/nest/journey-tracks.mcp.test.ts @@ -0,0 +1,196 @@ +/** + * JourneyTracksMcp (JRNTRK-MCP-001..005): the GPX tracks a journey's map draws, + * over MCP, on the same service and journey access as GET + * /api/journeys/:id/tracks. Each track answers with its size for the model and + * a thinned line under display_geometry for an MCP client that draws it, the + * lines of all tracks held to about 1000 points together; TREK's assistant + * keeps the lines away from the provider. + */ +import { ADDON_IDS } from '../../../src/addons'; +import { Places } from '../../../src/db/entities/Places.entity'; +import type { McpContext } from '../../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata'; +import { AddonsModule } from '../../../src/nest/addons/addons.module'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; +import { toolOutputForProvider } from '../../../src/nest/assistant/assistant-redact'; +import { JourneyDomainModule } from '../../../src/nest/journey/journey-domain.module'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { JourneyTracksMcp } from '../../../src/nest/journey/journey-tracks.mcp'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { addJourneyContributor, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys'; +import { makePlace } from '../../helpers/factories/places'; +import { updateRows } from '../../helpers/factories/rows'; +import { makeTrip } from '../../helpers/factories/trips'; +import { makeUser } from '../../helpers/factories/users'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let orm: TestOrm; +let mcp: JourneyTracksMcp; + +beforeAll(async () => { + t = await createTestModule({ db, imports: [JourneyDomainModule, AddonsModule], providers: [JourneyTracksMcp] }); + mcp = t.get(JourneyTracksMcp); + orm = await createTestOrm(db); +}); +beforeEach(() => { + resetTestDb(db); + orm.clear(); + vi.restoreAllMocks(); +}); +afterAll(async () => { + await orm.close(); + await t.close(); + db.close(); +}); + +const ctx = (userId: number) => ({ userId }) as McpContext; + +function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) { + const text = result.content[0]?.text ?? ''; + return result.isError ? { error: text } : JSON.parse(text); +} + +/** A place of the trip carrying a route geometry, drawn in one colour. */ +async function placeWithGeometry(tripId: number, name: string, points: [number, number][]) { + const place = await makePlace(orm, tripId, { name }); + await updateRows(orm, Places, { id: place.id }, { route_geometry: JSON.stringify(points), route_color: '#d9480f' }); + return place.id; +} + +/** A journey written about one trip, one of whose places carries the given track. */ +async function journeyWithTrack(name: string, points: [number, number][]) { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { title: 'Dolomites' }); + const journey = await makeJourney(orm, user.id); + await makeJourneyEntry(orm, journey.id, user.id, { sourceTrip: trip.id }); + const placeId = await placeWithGeometry(trip.id, name, points); + return { user, trip, journey, placeId }; +} + +describe('get_journey_tracks', () => { + it('JRNTRK-MCP-001: answers each track by its trip, name, kind, size and length, with a line to draw', async () => { + // Two hundredths of a degree along the equator: 2.22 km. + const f = await journeyWithTrack('Ridge', [ + [0.000001234, 0], + [0, 0.01], + [0, 0.02], + ]); + // A single point is a pin, no track. + await placeWithGeometry(f.trip.id, 'Summit', [[46.5, 11.8]]); + + const answer = read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(f.user.id))); + + expect(answer).toEqual({ + tracks: [ + { + place_id: f.placeId, + trip_id: f.trip.id, + name: 'Ridge', + kind: 'trip_gpx', + color: '#d9480f', + point_count: 3, + distance_km: 2.2, + display_geometry: [ + [0, 0], + [0, 0.01], + [0, 0.02], + ], + }, + ], + }); + }); + + it('JRNTRK-MCP-002: a long track keeps its length and its ends, with at most 200 points to draw, none of which reach the provider', async () => { + const points = Array.from({ length: 1000 }, (_, i): [number, number] => [46 + i / 10_000, 11 + i / 10_000]); + const f = await journeyWithTrack('Traverse', points); + + const answer = read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(f.user.id))); + + const [track] = answer.tracks; + expect(track.point_count).toBe(1000); + expect(track.display_geometry).toHaveLength(200); + expect(track.display_geometry[0]).toEqual([46, 11]); + expect(track.display_geometry[199]).toEqual([46.0999, 11.0999]); + expect(track.distance_km).toBeGreaterThan(13); + // The line is for an MCP client that draws it; TREK's assistant hands the model the summary alone. + expect(toolOutputForProvider('get_journey_tracks', answer)).toEqual({ + tracks: [ + { + place_id: track.place_id, + trip_id: f.trip.id, + name: 'Traverse', + kind: 'trip_gpx', + color: '#d9480f', + point_count: 1000, + distance_km: track.distance_km, + }, + ], + }); + }); + + it.each([ + [8, 1000, 125], + [3, 1000, 200], + [700, 10, 2], + ])( + 'JRNTRK-MCP-005: %i tracks of %i points share the lines: %i points each, both ends kept, about 1000 in all', + async (count, length, each) => { + const tracks = Array.from({ length: count }, (_, n) => ({ + place_id: n + 1, + trip_id: 1, + name: `Track ${n + 1}`, + color: null, + points: Array.from({ length }, (_, i): [number, number] => [46 + i / 1000, 11 + n / 1000]), + })); + vi.spyOn(t.get(JourneyDomainService), 'journeyTracks').mockResolvedValue(tracks); + + const answer = read(await mcp.getJourneyTracks({ journeyId: 1 }, ctx(1))); + + type Line = [number, number][]; + const lines: Line[] = answer.tracks.map((track: { display_geometry: Line }) => track.display_geometry); + const rounded = ([lat, lng]: [number, number]) => [Math.round(lat * 1e5) / 1e5, Math.round(lng * 1e5) / 1e5]; + expect(lines.map((line) => line.length)).toEqual(tracks.map(() => each)); + expect(lines.map((line) => [line[0], line[line.length - 1]])).toEqual( + tracks.map((track) => [rounded(track.points[0]), rounded(track.points[length - 1])]), + ); + expect(lines.flat().length).toBeLessThanOrEqual(Math.max(1000, count * 2)); + expect(answer.tracks.map((track: { point_count: number }) => track.point_count)).toEqual( + tracks.map(() => length), + ); + }, + ); + + it('JRNTRK-MCP-003: a contributor reads them, a stranger gets the journey refusal', async () => { + const f = await journeyWithTrack('Ridge', [ + [46, 11], + [46.01, 11.01], + ]); + const { user: viewer } = await makeUser(orm); + const { user: stranger } = await makeUser(orm); + await addJourneyContributor(orm, f.journey.id, viewer.id, 'viewer'); + + expect(read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(viewer.id))).tracks).toHaveLength(1); + expect(read(await mcp.getJourneyTracks({ journeyId: f.journey.id }, ctx(stranger.id)))).toEqual({ + error: 'Journey not found or access denied.', + }); + }); + + it('JRNTRK-MCP-004: is a read of the journey under the Journey addon', async () => { + const entry = getEntry(JourneyTracksMcp as unknown as ClassRef, 'getJourneyTracks'); + if (entry?.kind !== 'tool') throw new Error('getJourneyTracks is no tool'); + expect(entry.options.name).toBe('get_journey_tracks'); + expect(entry.options.access).toEqual({ group: 'journey', mode: 'read' }); + expect(entry.options.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + const enabled = vi.spyOn(t.get(AddonsService), 'isAddonEnabled').mockResolvedValue(false); + expect(await entry.options.when?.(ctx(1), mcp)).toBe(false); + expect(enabled).toHaveBeenCalledWith(ADDON_IDS.JOURNEY); + enabled.mockResolvedValue(true); + expect(await entry.options.when?.(ctx(1), mcp)).toBe(true); + }); +}); diff --git a/server/tests/unit/nest/weather/weather.mcp.test.ts b/server/tests/unit/nest/weather/weather.mcp.test.ts new file mode 100644 index 000000000..4edd386e0 --- /dev/null +++ b/server/tests/unit/nest/weather/weather.mcp.test.ts @@ -0,0 +1,146 @@ +/** + * WeatherMcp get_wind_forecast (WIND-MCP-001..005): the wind for a place on + * one day over MCP, the twin of GET /api/weather/wind. The tool comes out of + * the container with the real WindService; Open-Meteo is a stubbed fetch, so + * nothing leaves the test. The dates are counted from the real today, because + * the tool asks the service without a clock of its own. + */ +import type { McpContext } from '../../../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata'; +import { WeatherMcp } from '../../../../src/nest/weather/weather.mcp'; +import { WeatherModule } from '../../../../src/nest/weather/weather.module'; +import { WIND_UNAVAILABLE_MESSAGE, WindService } from '../../../../src/nest/weather/wind.service'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { createTestModule, type TestModule } from '../../../helpers/test-module'; +import { windQuerySchema } from '@trek/shared'; + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { z, type ZodRawShape } from 'zod'; + +// The failed request's warning stays out of the log file; everything else of the logger is real. +vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({ + ...(await importOriginal()), + logWarn: vi.fn(), +})); + +const db = createSnapshotTestDb(); +let t: TestModule; +let mcp: WeatherMcp; + +beforeAll(async () => { + t = await createTestModule({ db, imports: [WeatherModule] }); + mcp = t.get(WeatherMcp); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); +afterAll(async () => { + await t.close(); + db.close(); +}); + +const ctx = { userId: 1 } as McpContext; + +/** The UTC day `days` from today, as YYYY-MM-DD. */ +function dayFromToday(days: number): string { + return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10); +} + +function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) { + const text = result.content[0]?.text ?? ''; + return result.isError ? { error: text } : JSON.parse(text); +} + +function answer(body: unknown, status = 200) { + return vi.fn( + async () => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }), + ); +} + +function openMeteo(date: string) { + return { + timezone: 'Europe/Lisbon', + hourly: { + time: [`${date}T09:00`, `${date}T15:00`], + wind_speed_10m: [12.4, 27.6], + wind_gusts_10m: [20.2, null], + wind_direction_10m: [315, 290.6], + }, + daily: { wind_speed_10m_max: [27.6], wind_gusts_10m_max: [44.8], wind_direction_10m_dominant: [300] }, + }; +} + +function toolOptions() { + const entry = getEntry(WeatherMcp as unknown as ClassRef, 'getWindForecast'); + if (entry?.kind !== 'tool') throw new Error('getWindForecast is no tool'); + return entry.options; +} + +describe('get_wind_forecast', () => { + it('WIND-MCP-001: is a read of the weather for everyone signed in, on the input the REST query takes', () => { + const options = toolOptions(); + expect(options.name).toBe('get_wind_forecast'); + expect(options.access).toEqual({ group: 'weather', mode: 'read' }); + expect(options.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); + // Weather has no addon, like the route behind JwtAuthGuard alone. + expect(options.when).toBeUndefined(); + expect(options.inputSchema).toEqual(windQuerySchema.shape); + const input = z.object(options.inputSchema as ZodRawShape); + expect(input.safeParse({ lat: 38.7, lng: -9.1, date: '2026-10-21' }).success).toBe(true); + expect(input.safeParse({ lat: 91, lng: -9.1, date: '2026-10-21' }).success).toBe(false); + expect(input.safeParse({ lat: 38.7, lng: -9.1, date: '21.10.2026' }).success).toBe(false); + }); + + it('WIND-MCP-002: answers the hours and the day of the forecast under wind', async () => { + const date = dayFromToday(2); + const fetchSpy = answer(openMeteo(date)); + vi.stubGlobal('fetch', fetchSpy); + + const result = read(await mcp.getWindForecast({ lat: 38.71, lng: -9.14, date }, ctx)); + + expect(result.wind).toMatchObject({ + status: 'forecast', + date, + lat: 38.71, + lng: -9.14, + timezone: 'Europe/Lisbon', + source: 'Open-Meteo', + hours: [ + { hour: 9, speed_kmh: 12, gusts_kmh: 20, direction_deg: 315 }, + { hour: 15, speed_kmh: 28, gusts_kmh: null, direction_deg: 291 }, + ], + day: { speed_max_kmh: 28, gusts_max_kmh: 45, direction_dominant_deg: 300 }, + }); + expect(String(fetchSpy.mock.calls[0]?.[0 as never])).toContain('latitude=38.71&longitude=-9.14'); + }); + + it('WIND-MCP-003: a day gone and a day past the forecast are named without figures and without a request', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const past = read(await mcp.getWindForecast({ lat: 40, lng: -8, date: dayFromToday(-3) }, ctx)); + const far = read(await mcp.getWindForecast({ lat: 40, lng: -8, date: dayFromToday(40) }, ctx)); + + expect([past.wind.status, far.wind.status]).toEqual(['past', 'beyond_horizon']); + expect([past.wind.hours, far.wind.day]).toEqual([[], null]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('WIND-MCP-004: a source that fails is a refusal with the route sentence, not a transport error', async () => { + vi.stubGlobal('fetch', answer({ error: true }, 503)); + + const result = await mcp.getWindForecast({ lat: 41.15, lng: -8.61, date: dayFromToday(1) }, ctx); + + expect(result.isError).toBe(true); + expect(read(result)).toEqual({ error: WIND_UNAVAILABLE_MESSAGE }); + }); + + it('WIND-MCP-005: a failure without a message of its own still reads as a sentence', async () => { + vi.spyOn(t.get(WindService), 'forecast').mockRejectedValueOnce('timeout'); + + const result = await mcp.getWindForecast({ lat: 37.02, lng: -7.93, date: dayFromToday(1) }, ctx); + + expect(read(result)).toEqual({ error: 'Weather service not available.' }); + }); +}); diff --git a/shared/src/budget/budget.schema.spec.ts b/shared/src/budget/budget.schema.spec.ts index 6b9773fa4..7f36ee5d3 100644 --- a/shared/src/budget/budget.schema.spec.ts +++ b/shared/src/budget/budget.schema.spec.ts @@ -9,6 +9,8 @@ import { budgetUpdateMembersRequestSchema, budgetToggleMemberPaidRequestSchema, budgetReorderItemsRequestSchema, + budgetItemsPageSchema, + BUDGET_ITEMS_PAGE_DEFAULT, COST_CATEGORIES, typeToCostCategory, } from './budget.schema'; @@ -157,3 +159,15 @@ describe('budgetUnconvertedSchema', () => { ); }); }); + +describe('budgetItemsPageSchema', () => { + it('takes no paging at all, or an offset and a limit of 1 to 100 whole expenses', () => { + expect(budgetItemsPageSchema.safeParse({}).success).toBe(true); + expect(budgetItemsPageSchema.safeParse({ offset: 0, limit: 1 }).success).toBe(true); + expect(budgetItemsPageSchema.safeParse({ offset: 150, limit: 100 }).success).toBe(true); + for (const page of [{ offset: -1 }, { offset: 1.5 }, { limit: 0 }, { limit: 101 }, { limit: '10' }]) { + expect(budgetItemsPageSchema.safeParse(page).success, JSON.stringify(page)).toBe(false); + } + expect(BUDGET_ITEMS_PAGE_DEFAULT).toBe(50); + }); +}); diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index ac47de021..ff6014df8 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -421,3 +421,30 @@ export const budgetReorderCategoriesRequestSchema = z.object({ orderedCategories: z.array(z.string()), }); export type BudgetReorderCategoriesRequest = z.infer; + +/** How many expenses one page of the MCP expense list holds when the caller does not say. */ +export const BUDGET_ITEMS_PAGE_DEFAULT = 50; +const BUDGET_ITEMS_PAGE_MAX = 100; + +/** + * One page of the expense list an MCP client reads (list_budget_items): the + * expenses in their list order from `offset`, at most `limit` of them. A + * budget of a few hundred rows is too long for one answer to a model, so it + * pages; the totals next to the page always cover the whole trip. + */ +export const budgetItemsPageSchema = z.object({ + offset: z + .number() + .int() + .min(0) + .optional() + .describe('How many expenses to skip: the next_offset of the page before (default 0)'), + limit: z + .number() + .int() + .min(1) + .max(BUDGET_ITEMS_PAGE_MAX) + .optional() + .describe(`How many expenses at most (default ${BUDGET_ITEMS_PAGE_DEFAULT}, at most ${BUDGET_ITEMS_PAGE_MAX})`), +}); +export type BudgetItemsPage = z.infer; diff --git a/shared/src/dawarich/dawarich.schema.spec.ts b/shared/src/dawarich/dawarich.schema.spec.ts index 21be1988b..e8b4f5153 100644 --- a/shared/src/dawarich/dawarich.schema.spec.ts +++ b/shared/src/dawarich/dawarich.schema.spec.ts @@ -30,6 +30,8 @@ import { dawarichSuggestionStateSchemaBody, dawarichSuggestionTargetSchema, dawarichSyncStateSchema, + dawarichTimeWindowSchema, + DAWARICH_WINDOW_MAX_DAYS, dawarichTrackDaySchema, dawarichTrackSchema, dawarichTrackSegmentSchema, @@ -39,7 +41,7 @@ import { import { describe, expect, it } from 'vitest'; /** - * DAWARICH-SCHEMA-001..043: the Dawarich contracts. + * DAWARICH-SCHEMA-001..044: the Dawarich contracts. * * This is the layer both sides of the integration agree on, and the feature is * built out of things that are deliberately lenient in one place and @@ -734,3 +736,16 @@ describe('the tuning numbers', () => { expect(DAWARICH_SYNC_LOOKAHEAD_DAYS).toBe(1); }); }); + +describe('dawarichTimeWindowSchema', () => { + it('DAWARICH-SCHEMA-044 needs both ends, each a non-empty string, and leaves the order and the 400-day span to the server', () => { + // The order and the span are the server's to check (parseTimeWindow), because + // the MCP input is this shape's fields and a refinement on the object would + // never run there. What the schema itself owes is that neither end is missing. + expect(dawarichTimeWindowSchema.safeParse({ from: '2025-01-01', to: '2025-12-31' }).success).toBe(true); + expect(dawarichTimeWindowSchema.safeParse({ from: '2025-01-01' }).success).toBe(false); + expect(dawarichTimeWindowSchema.safeParse({ from: '', to: '2025-12-31' }).success).toBe(false); + expect(dawarichTimeWindowSchema.safeParse({ from: 20250101, to: '2025-12-31' }).success).toBe(false); + expect(DAWARICH_WINDOW_MAX_DAYS).toBe(400); + }); +}); diff --git a/shared/src/dawarich/dawarich.schema.ts b/shared/src/dawarich/dawarich.schema.ts index ce3ad1805..079757e32 100644 --- a/shared/src/dawarich/dawarich.schema.ts +++ b/shared/src/dawarich/dawarich.schema.ts @@ -331,6 +331,30 @@ export const dawarichTrackSchema = z.object({ }); export type DawarichTrack = z.infer; +/** The longest window one read of the recordings may span. */ +export const DAWARICH_WINDOW_MAX_DAYS = 400; + +/** + * An explicit window of the recordings, as GET /atlas/suggestions and GET + * /track take it in their query and the MCP tools take it as input. Both ends + * are required and `to` lies after `from`, within DAWARICH_WINDOW_MAX_DAYS: + * a half-open range would read everything since the epoch, the request that + * makes an instance time out. The server checks the order and the span. + */ +export const dawarichTimeWindowSchema = z.object({ + from: z + .string() + .min(1) + .describe('Start of the window, ISO 8601 (2025-06-01 or 2025-06-01T00:00:00Z); a bare date is its midnight UTC'), + to: z + .string() + .min(1) + .describe( + `End of the window, ISO 8601, after from and at most ${DAWARICH_WINDOW_MAX_DAYS} days later; a bare date is its midnight UTC, so give the day after the last one to include it`, + ), +}); +export type DawarichTimeWindow = z.infer; + // ── Atlas ──────────────────────────────────────────────────────────────────── /** diff --git a/shared/src/i18n/ar/assistant.ts b/shared/src/i18n/ar/assistant.ts index 343aa9847..9b845571d 100644 --- a/shared/src/i18n/ar/assistant.ts +++ b/shared/src/i18n/ar/assistant.ts @@ -115,6 +115,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'قراءة الحجز', 'assistant.step.planningHike': 'تخطيط رحلة المشي', 'assistant.step.planningHikeTo': 'تخطيط رحلة المشي إلى {place}', + 'assistant.step.expenses': 'قراءة النفقات', + 'assistant.step.shares': 'حساب الحصص', + 'assistant.step.wind': 'التحقق من الرياح', + 'assistant.step.tracks': 'قراءة مسارات GPX', + 'assistant.step.bucketScan': 'مقارنة قائمة الأمنيات بتسجيلاتك', + 'assistant.step.atlasCountries': 'البحث عن الدول في تسجيلاتك', 'assistant.step.working': 'جارٍ العمل على ذلك', 'assistant.step.collapsed': '{count} خطوة', 'assistant.step.collapsed.zero': 'لا خطوات', @@ -516,6 +522,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'إضافة وسيلة نقل', 'assistant.tool.toggle_vacay_entry': 'تحديد يوم إجازة', 'assistant.tool.mark_country_visited': 'تحديد البلد كمُزار', + 'assistant.tool.confirm_bucket_list_visits': 'الشطب في قائمة الأمنيات', + 'assistant.tool.clear_bucket_list_visit': 'إلغاء الشطب في قائمة الأمنيات', + 'assistant.tool.accept_atlas_countries': 'إضافة الدول إلى الأطلس', 'assistant.tool.send_collab_message': 'إرسال إلى محادثة الرحلة', 'assistant.admin.chatgptShared': 'أنت تشارك اشتراكك الشخصي في ChatGPT مع الجميع على هذا الخادم: تُحتسب محادثاتهم من خطتك. تحقّق من أن خطتك وشروط OpenAI تسمح بذلك.', diff --git a/shared/src/i18n/az/assistant.ts b/shared/src/i18n/az/assistant.ts index 7f6e4f43d..571402138 100644 --- a/shared/src/i18n/az/assistant.ts +++ b/shared/src/i18n/az/assistant.ts @@ -115,6 +115,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Bron oxunur', 'assistant.step.planningHike': 'Yürüş planlaşdırılır', 'assistant.step.planningHikeTo': '{place} istiqamətində yürüş planlaşdırılır', + 'assistant.step.expenses': 'Xərclər oxunur', + 'assistant.step.shares': 'Paylar hesablanır', + 'assistant.step.wind': 'Külək yoxlanılır', + 'assistant.step.tracks': 'GPX izləri oxunur', + 'assistant.step.bucketScan': 'Arzular siyahısı qeydlərinizlə tutuşdurulur', + 'assistant.step.atlasCountries': 'Qeydlərinizdə ölkələr axtarılır', 'assistant.step.working': 'Üzərində işləyirəm', 'assistant.step.collapsed': '{count} addım', 'assistant.step.collapsed.one': '{count} addım', @@ -436,6 +442,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Nəqliyyat əlavə et', 'assistant.tool.toggle_vacay_entry': 'Məzuniyyət günü təyin et', 'assistant.tool.mark_country_visited': 'Ölkəni ziyarət edilmiş kimi işarələ', + 'assistant.tool.confirm_bucket_list_visits': 'Arzular siyahısında tamamlandı kimi işarələ', + 'assistant.tool.clear_bucket_list_visit': 'Arzular siyahısında işarəni geri götür', + 'assistant.tool.accept_atlas_countries': 'Ölkələri Atlasa əlavə et', 'assistant.tool.send_collab_message': 'Səyahət söhbətinə göndər', 'assistant.admin.chatgptShared': 'Öz ChatGPT abunəliyini bu serverdəki hər kəslə paylaşırsan: onların söhbətləri sənin planından çıxılır. Planının və OpenAI şərtlərinin buna icazə verdiyini yoxla.', diff --git a/shared/src/i18n/br/assistant.ts b/shared/src/i18n/br/assistant.ts index 40b13c35b..e097648e6 100644 --- a/shared/src/i18n/br/assistant.ts +++ b/shared/src/i18n/br/assistant.ts @@ -116,6 +116,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Lendo a reserva', 'assistant.step.planningHike': 'Planejando a trilha', 'assistant.step.planningHikeTo': 'Planejando a trilha até {place}', + 'assistant.step.expenses': 'Lendo as despesas', + 'assistant.step.shares': 'Calculando a parte de cada um', + 'assistant.step.wind': 'Verificando o vento', + 'assistant.step.tracks': 'Lendo as trilhas GPX', + 'assistant.step.bucketScan': 'Conferindo a lista de desejos com suas gravações', + 'assistant.step.atlasCountries': 'Procurando países nas suas gravações', 'assistant.step.working': 'Trabalhando nisso', 'assistant.step.collapsed': '{count} etapas', 'assistant.step.collapsed.one': '{count} etapa', @@ -437,6 +443,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Adicionar transporte', 'assistant.tool.toggle_vacay_entry': 'Marcar um dia de férias', 'assistant.tool.mark_country_visited': 'Marcar o país como visitado', + 'assistant.tool.confirm_bucket_list_visits': 'Riscar na lista de desejos', + 'assistant.tool.clear_bucket_list_visit': 'Desfazer o risco na lista de desejos', + 'assistant.tool.accept_atlas_countries': 'Adicionar os países ao Atlas', 'assistant.tool.send_collab_message': 'Enviar para o chat da viagem', 'assistant.admin.chatgptShared': 'Você compartilha seu próprio plano do ChatGPT com todos neste servidor: os chats deles contam no seu plano. Confira se o seu plano e os termos da OpenAI permitem isso.', diff --git a/shared/src/i18n/ca/assistant.ts b/shared/src/i18n/ca/assistant.ts index 762261908..aa906c443 100644 --- a/shared/src/i18n/ca/assistant.ts +++ b/shared/src/i18n/ca/assistant.ts @@ -116,6 +116,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Llegint la reserva', 'assistant.step.planningHike': 'Planificant l’excursió', 'assistant.step.planningHikeTo': 'Planificant l’excursió fins a {place}', + 'assistant.step.expenses': 'Llegint les despeses', + 'assistant.step.shares': 'Calculant la part de cadascú', + 'assistant.step.wind': 'Consultant el vent', + 'assistant.step.tracks': 'Llegint les traces GPX', + 'assistant.step.bucketScan': 'Comprovant la llista de desitjos amb els teus enregistraments', + 'assistant.step.atlasCountries': 'Cercant països als teus enregistraments', 'assistant.step.working': 'Hi estic treballant', 'assistant.step.collapsed': '{count} passos', 'assistant.step.collapsed.one': '{count} pas', @@ -439,6 +445,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Afegeix transport', 'assistant.tool.toggle_vacay_entry': 'Marca un dia de vacances', 'assistant.tool.mark_country_visited': 'Marca el país com a visitat', + 'assistant.tool.confirm_bucket_list_visits': 'Marca a la llista de desitjos', + 'assistant.tool.clear_bucket_list_visit': 'Desmarca a la llista de desitjos', + 'assistant.tool.accept_atlas_countries': 'Afegeix els països a l’Atlas', 'assistant.tool.send_collab_message': 'Envia al xat del viatge', 'assistant.admin.chatgptShared': 'Comparteixes el teu propi pla de ChatGPT amb tothom en aquest servidor: els seus xats compten contra el teu pla. Comprova que el teu pla i les condicions d’OpenAI ho permeten.', diff --git a/shared/src/i18n/cs/assistant.ts b/shared/src/i18n/cs/assistant.ts index f469f93e3..5a1a8c8f2 100644 --- a/shared/src/i18n/cs/assistant.ts +++ b/shared/src/i18n/cs/assistant.ts @@ -115,6 +115,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Čtu rezervaci', 'assistant.step.planningHike': 'Plánuji túru', 'assistant.step.planningHikeTo': 'Plánuji túru do místa {place}', + 'assistant.step.expenses': 'Čtu výdaje', + 'assistant.step.shares': 'Počítám podíly', + 'assistant.step.wind': 'Zjišťuji vítr', + 'assistant.step.tracks': 'Čtu GPX stopy', + 'assistant.step.bucketScan': 'Porovnávám seznam přání s vašimi záznamy', + 'assistant.step.atlasCountries': 'Hledám země ve vašich záznamech', 'assistant.step.working': 'Pracuji na tom', 'assistant.step.collapsed': '{count} kroků', 'assistant.step.collapsed.one': '{count} krok', @@ -455,6 +461,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Přidat dopravu', 'assistant.tool.toggle_vacay_entry': 'Nastavit den dovolené', 'assistant.tool.mark_country_visited': 'Označit zemi jako navštívenou', + 'assistant.tool.confirm_bucket_list_visits': 'Odškrtnout v seznamu přání', + 'assistant.tool.clear_bucket_list_visit': 'Zrušit odškrtnutí v seznamu přání', + 'assistant.tool.accept_atlas_countries': 'Přidat země do Atlasu', 'assistant.tool.send_collab_message': 'Poslat do chatu cesty', 'assistant.admin.chatgptShared': 'Sdílíš své vlastní předplatné ChatGPT se všemi na tomto serveru: jejich chaty se počítají do tvého tarifu. Ověř, že to tvůj tarif a podmínky OpenAI dovolují.', diff --git a/shared/src/i18n/de/assistant.ts b/shared/src/i18n/de/assistant.ts index c32075b6b..c09efdf0e 100644 --- a/shared/src/i18n/de/assistant.ts +++ b/shared/src/i18n/de/assistant.ts @@ -115,6 +115,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Liest die Buchung', 'assistant.step.planningHike': 'Plant die Wanderung', 'assistant.step.planningHikeTo': 'Plant die Wanderung bis {place}', + 'assistant.step.expenses': 'Liest die Ausgaben', + 'assistant.step.shares': 'Rechnet die Anteile aus', + 'assistant.step.wind': 'Prüft den Wind', + 'assistant.step.tracks': 'Liest die GPX-Tracks', + 'assistant.step.bucketScan': 'Gleicht die Wunschliste mit deinen Aufzeichnungen ab', + 'assistant.step.atlasCountries': 'Sucht Länder in deinen Aufzeichnungen', 'assistant.step.working': 'Arbeitet daran', 'assistant.step.collapsed': '{count} Schritte', 'assistant.step.collapsed.one': '{count} Schritt', @@ -436,6 +442,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Verbindung hinzufügen', 'assistant.tool.toggle_vacay_entry': 'Urlaubstag setzen', 'assistant.tool.mark_country_visited': 'Land als besucht markieren', + 'assistant.tool.confirm_bucket_list_visits': 'Auf der Wunschliste abhaken', + 'assistant.tool.clear_bucket_list_visit': 'Haken auf der Wunschliste entfernen', + 'assistant.tool.accept_atlas_countries': 'Länder in den Atlas übernehmen', 'assistant.tool.send_collab_message': 'In den Trip-Chat senden', 'assistant.admin.chatgptShared': 'Du teilst dein eigenes ChatGPT-Abo mit allen auf diesem Server: Ihre Chats zählen gegen dein Kontingent. Prüfe, ob dein Abo und die Bedingungen von OpenAI das erlauben.', diff --git a/shared/src/i18n/en/assistant.ts b/shared/src/i18n/en/assistant.ts index e7a838f37..e3eec450e 100644 --- a/shared/src/i18n/en/assistant.ts +++ b/shared/src/i18n/en/assistant.ts @@ -112,6 +112,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Reading the booking', 'assistant.step.planningHike': 'Planning the hike', 'assistant.step.planningHikeTo': 'Planning the hike to {place}', + 'assistant.step.expenses': 'Reading the expenses', + 'assistant.step.shares': 'Working out the shares', + 'assistant.step.wind': 'Checking the wind', + 'assistant.step.tracks': 'Reading the GPX tracks', + 'assistant.step.bucketScan': 'Checking your wishlist against your recordings', + 'assistant.step.atlasCountries': 'Looking for countries in your recordings', 'assistant.step.working': 'Working on it', 'assistant.step.collapsed': '{count} steps', 'assistant.step.collapsed.one': '{count} step', @@ -431,6 +437,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Add transport', 'assistant.tool.toggle_vacay_entry': 'Set a vacation day', 'assistant.tool.mark_country_visited': 'Mark the country as visited', + 'assistant.tool.confirm_bucket_list_visits': 'Tick off on the wishlist', + 'assistant.tool.clear_bucket_list_visit': 'Untick on the wishlist', + 'assistant.tool.accept_atlas_countries': 'Add the countries to the Atlas', 'assistant.tool.send_collab_message': 'Send to the trip chat', 'assistant.admin.chatgptShared': 'You share your own ChatGPT plan with everyone on this server: their chats count against your plan. Check that your plan and OpenAI’s terms allow this.', diff --git a/shared/src/i18n/es/assistant.ts b/shared/src/i18n/es/assistant.ts index 42df09a8b..de4200b11 100644 --- a/shared/src/i18n/es/assistant.ts +++ b/shared/src/i18n/es/assistant.ts @@ -114,6 +114,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Leyendo la reserva', 'assistant.step.planningHike': 'Planificando la excursión', 'assistant.step.planningHikeTo': 'Planificando la excursión hasta {place}', + 'assistant.step.expenses': 'Leyendo los gastos', + 'assistant.step.shares': 'Calculando la parte de cada uno', + 'assistant.step.wind': 'Consultando el viento', + 'assistant.step.tracks': 'Leyendo las rutas GPX', + 'assistant.step.bucketScan': 'Contrastando la lista de deseos con tus grabaciones', + 'assistant.step.atlasCountries': 'Buscando países en tus grabaciones', 'assistant.step.working': 'Trabajando en ello', 'assistant.step.collapsed': '{count} pasos', 'assistant.step.collapsed.one': '{count} paso', @@ -434,6 +440,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Añadir transporte', 'assistant.tool.toggle_vacay_entry': 'Marcar un día de vacaciones', 'assistant.tool.mark_country_visited': 'Marcar el país como visitado', + 'assistant.tool.confirm_bucket_list_visits': 'Tachar de la lista de deseos', + 'assistant.tool.clear_bucket_list_visit': 'Quitar la marca en la lista de deseos', + 'assistant.tool.accept_atlas_countries': 'Añadir los países al Atlas', 'assistant.tool.send_collab_message': 'Enviar al chat del viaje', 'assistant.admin.chatgptShared': 'Compartes tu propio plan de ChatGPT con todos en este servidor: sus chats cuentan contra tu plan. Comprueba que tu plan y las condiciones de OpenAI lo permiten.', diff --git a/shared/src/i18n/et/assistant.ts b/shared/src/i18n/et/assistant.ts index 0220b1052..aa07ca658 100644 --- a/shared/src/i18n/et/assistant.ts +++ b/shared/src/i18n/et/assistant.ts @@ -115,6 +115,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Loen broneeringut', 'assistant.step.planningHike': 'Planeerin matka', 'assistant.step.planningHikeTo': 'Planeerin matka sihtkohta {place}', + 'assistant.step.expenses': 'Loen kulusid', + 'assistant.step.shares': 'Arvutan osasid', + 'assistant.step.wind': 'Kontrollin tuult', + 'assistant.step.tracks': 'Loen GPX-radu', + 'assistant.step.bucketScan': 'Võrdlen soovinimekirja sinu salvestustega', + 'assistant.step.atlasCountries': 'Otsin riike sinu salvestustest', 'assistant.step.working': 'Töötan selle kallal', 'assistant.step.collapsed': '{count} sammu', 'assistant.step.collapsed.one': '{count} samm', @@ -435,6 +441,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Lisa transport', 'assistant.tool.toggle_vacay_entry': 'Määra puhkusepäev', 'assistant.tool.mark_country_visited': 'Märgi riik külastatuks', + 'assistant.tool.confirm_bucket_list_visits': 'Märgi soovinimekirjas täidetuks', + 'assistant.tool.clear_bucket_list_visit': 'Võta soovinimekirjas märge tagasi', + 'assistant.tool.accept_atlas_countries': 'Lisa riigid Atlasesse', 'assistant.tool.send_collab_message': 'Saada reisivestlusesse', 'assistant.admin.chatgptShared': 'Jagad oma ChatGPT tellimust kõigiga selles serveris: nende vestlused arvestatakse sinu paketist. Kontrolli, kas sinu pakett ja OpenAI tingimused seda lubavad.', diff --git a/shared/src/i18n/fr/assistant.ts b/shared/src/i18n/fr/assistant.ts index 7dcd9ae53..2105b0927 100644 --- a/shared/src/i18n/fr/assistant.ts +++ b/shared/src/i18n/fr/assistant.ts @@ -116,6 +116,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Lecture de la réservation', 'assistant.step.planningHike': 'Planification de la randonnée', 'assistant.step.planningHikeTo': 'Planification de la randonnée jusqu’à {place}', + 'assistant.step.expenses': 'Lecture des dépenses', + 'assistant.step.shares': 'Calcul des parts', + 'assistant.step.wind': 'Consultation du vent', + 'assistant.step.tracks': 'Lecture des traces GPX', + 'assistant.step.bucketScan': 'Confrontation de la liste de souhaits à vos enregistrements', + 'assistant.step.atlasCountries': 'Recherche des pays dans vos enregistrements', 'assistant.step.working': 'Travail en cours', 'assistant.step.collapsed': '{count} étapes', 'assistant.step.collapsed.one': '{count} étape', @@ -441,6 +447,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Ajouter un transport', 'assistant.tool.toggle_vacay_entry': 'Poser un jour de congé', 'assistant.tool.mark_country_visited': 'Marquer le pays comme visité', + 'assistant.tool.confirm_bucket_list_visits': 'Cocher dans la liste de souhaits', + 'assistant.tool.clear_bucket_list_visit': 'Décocher dans la liste de souhaits', + 'assistant.tool.accept_atlas_countries': 'Ajouter les pays à l’Atlas', 'assistant.tool.send_collab_message': 'Envoyer dans la discussion du voyage', 'assistant.admin.chatgptShared': 'Vous partagez votre propre abonnement ChatGPT avec tout le monde sur ce serveur : leurs discussions comptent sur votre forfait. Vérifiez que votre forfait et les conditions d’OpenAI le permettent.', diff --git a/shared/src/i18n/gr/assistant.ts b/shared/src/i18n/gr/assistant.ts index ad5c0cc0a..95ac92dbd 100644 --- a/shared/src/i18n/gr/assistant.ts +++ b/shared/src/i18n/gr/assistant.ts @@ -118,6 +118,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Διαβάζω την κράτηση', 'assistant.step.planningHike': 'Σχεδιάζω την πεζοπορία', 'assistant.step.planningHikeTo': 'Σχεδιάζω την πεζοπορία προς {place}', + 'assistant.step.expenses': 'Διαβάζω τα έξοδα', + 'assistant.step.shares': 'Υπολογίζω τα μερίδια', + 'assistant.step.wind': 'Έλεγχος ανέμου', + 'assistant.step.tracks': 'Διαβάζω τις διαδρομές GPX', + 'assistant.step.bucketScan': 'Ελέγχω τη λίστα επιθυμιών με τις καταγραφές σας', + 'assistant.step.atlasCountries': 'Αναζητώ χώρες στις καταγραφές σας', 'assistant.step.working': 'Δουλεύω πάνω σε αυτό', 'assistant.step.collapsed': '{count} βήματα', 'assistant.step.collapsed.one': '{count} βήμα', @@ -440,6 +446,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Προσθήκη μεταφοράς', 'assistant.tool.toggle_vacay_entry': 'Ορισμός ημέρας άδειας', 'assistant.tool.mark_country_visited': 'Σήμανση χώρας ως επισκεφθείσας', + 'assistant.tool.confirm_bucket_list_visits': 'Σήμανση ως εκπληρωμένων στη λίστα επιθυμιών', + 'assistant.tool.clear_bucket_list_visit': 'Αναίρεση της σήμανσης στη λίστα επιθυμιών', + 'assistant.tool.accept_atlas_countries': 'Προσθήκη των χωρών στον Άτλαντα', 'assistant.tool.send_collab_message': 'Αποστολή στη συνομιλία ταξιδιού', 'assistant.admin.chatgptShared': 'Μοιράζεσαι τη δική σου συνδρομή ChatGPT με όλους σε αυτόν τον διακομιστή: οι συνομιλίες τους χρεώνονται στο δικό σου πρόγραμμα. Έλεγξε ότι το πρόγραμμά σου και οι όροι της OpenAI το επιτρέπουν.', diff --git a/shared/src/i18n/hu/assistant.ts b/shared/src/i18n/hu/assistant.ts index dafeeecc5..8e9789ea4 100644 --- a/shared/src/i18n/hu/assistant.ts +++ b/shared/src/i18n/hu/assistant.ts @@ -117,6 +117,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'A foglalás olvasása', 'assistant.step.planningHike': 'A túra tervezése', 'assistant.step.planningHikeTo': 'Túra tervezése ide: {place}', + 'assistant.step.expenses': 'A kiadások olvasása', + 'assistant.step.shares': 'A részesedések kiszámítása', + 'assistant.step.wind': 'A szél ellenőrzése', + 'assistant.step.tracks': 'A GPX-nyomvonalak olvasása', + 'assistant.step.bucketScan': 'A kívánságlista összevetése a rögzítéseiddel', + 'assistant.step.atlasCountries': 'Országok keresése a rögzítéseidben', 'assistant.step.working': 'Dolgozom rajta', 'assistant.step.collapsed': '{count} lépés', 'assistant.step.collapsed.one': '{count} lépés', @@ -441,6 +447,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Közlekedés hozzáadása', 'assistant.tool.toggle_vacay_entry': 'Szabadnap beállítása', 'assistant.tool.mark_country_visited': 'Ország megjelölése meglátogatottként', + 'assistant.tool.confirm_bucket_list_visits': 'Kipipálás a kívánságlistán', + 'assistant.tool.clear_bucket_list_visit': 'Pipa levétele a kívánságlistán', + 'assistant.tool.accept_atlas_countries': 'Az országok hozzáadása az Atlaszhoz', 'assistant.tool.send_collab_message': 'Küldés az utazási csevegésbe', 'assistant.admin.chatgptShared': 'A saját ChatGPT-előfizetésedet osztod meg mindenkivel ezen a szerveren: a csevegéseik a te keretedből fogynak. Ellenőrizd, hogy az előfizetésed és az OpenAI feltételei ezt megengedik-e.', diff --git a/shared/src/i18n/id/assistant.ts b/shared/src/i18n/id/assistant.ts index 4c873894d..15b9f776a 100644 --- a/shared/src/i18n/id/assistant.ts +++ b/shared/src/i18n/id/assistant.ts @@ -113,6 +113,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Membaca pemesanan', 'assistant.step.planningHike': 'Merencanakan pendakian', 'assistant.step.planningHikeTo': 'Merencanakan pendakian ke {place}', + 'assistant.step.expenses': 'Membaca pengeluaran', + 'assistant.step.shares': 'Menghitung bagian masing-masing', + 'assistant.step.wind': 'Memeriksa angin', + 'assistant.step.tracks': 'Membaca jalur GPX', + 'assistant.step.bucketScan': 'Mencocokkan daftar keinginan dengan rekamanmu', + 'assistant.step.atlasCountries': 'Mencari negara di rekamanmu', 'assistant.step.working': 'Sedang dikerjakan', 'assistant.step.collapsed': '{count} langkah', 'assistant.change.title': 'Perubahan yang disarankan', @@ -416,6 +422,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Tambah transportasi', 'assistant.tool.toggle_vacay_entry': 'Tetapkan hari cuti', 'assistant.tool.mark_country_visited': 'Tandai negara sebagai dikunjungi', + 'assistant.tool.confirm_bucket_list_visits': 'Coret di daftar keinginan', + 'assistant.tool.clear_bucket_list_visit': 'Batalkan coretan di daftar keinginan', + 'assistant.tool.accept_atlas_countries': 'Tambahkan negara ke Atlas', 'assistant.tool.send_collab_message': 'Kirim ke obrolan perjalanan', 'assistant.admin.chatgptShared': 'Kamu membagikan paket ChatGPT milikmu kepada semua orang di server ini: obrolan mereka dihitung dari paketmu. Pastikan paketmu dan ketentuan OpenAI mengizinkannya.', diff --git a/shared/src/i18n/it/assistant.ts b/shared/src/i18n/it/assistant.ts index 520d25e70..14aed3137 100644 --- a/shared/src/i18n/it/assistant.ts +++ b/shared/src/i18n/it/assistant.ts @@ -117,6 +117,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Leggo la prenotazione', 'assistant.step.planningHike': 'Pianifico l’escursione', 'assistant.step.planningHikeTo': 'Pianifico l’escursione fino a {place}', + 'assistant.step.expenses': 'Leggo le spese', + 'assistant.step.shares': 'Calcolo le quote', + 'assistant.step.wind': 'Controllo il vento', + 'assistant.step.tracks': 'Leggo le tracce GPX', + 'assistant.step.bucketScan': 'Confronto la lista dei desideri con le tue registrazioni', + 'assistant.step.atlasCountries': 'Cerco i paesi nelle tue registrazioni', 'assistant.step.working': 'Ci sto lavorando', 'assistant.step.collapsed': '{count} passaggi', 'assistant.step.collapsed.one': '{count} passaggio', @@ -442,6 +448,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Aggiungi trasporto', 'assistant.tool.toggle_vacay_entry': 'Segna un giorno di ferie', 'assistant.tool.mark_country_visited': 'Segna il paese come visitato', + 'assistant.tool.confirm_bucket_list_visits': 'Spunta nella lista dei desideri', + 'assistant.tool.clear_bucket_list_visit': 'Togli la spunta nella lista dei desideri', + 'assistant.tool.accept_atlas_countries': 'Aggiungi i paesi all’Atlas', 'assistant.tool.send_collab_message': 'Invia nella chat del viaggio', 'assistant.admin.chatgptShared': 'Condividi il tuo abbonamento ChatGPT con tutti su questo server: le loro chat contano sul tuo piano. Verifica che il tuo piano e i termini di OpenAI lo consentano.', diff --git a/shared/src/i18n/ja/assistant.ts b/shared/src/i18n/ja/assistant.ts index 9e5a6ff57..8e9197167 100644 --- a/shared/src/i18n/ja/assistant.ts +++ b/shared/src/i18n/ja/assistant.ts @@ -113,6 +113,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': '予約を読み込み中', 'assistant.step.planningHike': 'ハイキングを計画中', 'assistant.step.planningHikeTo': '{place}までのハイキングを計画中', + 'assistant.step.expenses': '支出を読み込み中', + 'assistant.step.shares': '各自の負担額を計算中', + 'assistant.step.wind': '風を確認中', + 'assistant.step.tracks': 'GPX トラックを読み込み中', + 'assistant.step.bucketScan': 'ウィッシュリストを記録と照合中', + 'assistant.step.atlasCountries': '記録から訪れた国を探し中', 'assistant.step.working': '処理中', 'assistant.step.collapsed': '{count} ステップ', 'assistant.change.title': '提案された変更', @@ -411,6 +417,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': '交通手段を追加', 'assistant.tool.toggle_vacay_entry': '休暇日を設定', 'assistant.tool.mark_country_visited': '国を訪問済みにする', + 'assistant.tool.confirm_bucket_list_visits': 'ウィッシュリストにチェックを付ける', + 'assistant.tool.clear_bucket_list_visit': 'ウィッシュリストのチェックを外す', + 'assistant.tool.accept_atlas_countries': '国をアトラスに追加', 'assistant.tool.send_collab_message': '旅行チャットに送信', 'assistant.admin.chatgptShared': '自分の ChatGPT プランをこのサーバーの全員と共有します。全員のチャットがあなたのプランの利用枠を使います。プランと OpenAI の規約で許可されているか確認してください。', diff --git a/shared/src/i18n/ko/assistant.ts b/shared/src/i18n/ko/assistant.ts index 3d48fb99f..0df6c0005 100644 --- a/shared/src/i18n/ko/assistant.ts +++ b/shared/src/i18n/ko/assistant.ts @@ -112,6 +112,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': '예약을 읽는 중', 'assistant.step.planningHike': '하이킹 계획 중', 'assistant.step.planningHikeTo': '{place}까지 하이킹 계획 중', + 'assistant.step.expenses': '지출을 읽는 중', + 'assistant.step.shares': '각자의 몫을 계산하는 중', + 'assistant.step.wind': '바람을 확인하는 중', + 'assistant.step.tracks': 'GPX 트랙을 읽는 중', + 'assistant.step.bucketScan': '위시리스트를 기록과 대조하는 중', + 'assistant.step.atlasCountries': '기록에서 방문한 나라를 찾는 중', 'assistant.step.working': '작업 중', 'assistant.step.collapsed': '{count}단계', 'assistant.change.title': '제안된 변경 사항', @@ -408,6 +414,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': '교통편 추가', 'assistant.tool.toggle_vacay_entry': '휴가일 지정', 'assistant.tool.mark_country_visited': '국가를 방문함으로 표시', + 'assistant.tool.confirm_bucket_list_visits': '위시리스트에서 완료 처리', + 'assistant.tool.clear_bucket_list_visit': '위시리스트 완료 표시 취소', + 'assistant.tool.accept_atlas_countries': '국가를 아틀라스에 추가', 'assistant.tool.send_collab_message': '여행 채팅으로 보내기', 'assistant.admin.chatgptShared': '내 ChatGPT 요금제를 이 서버의 모든 사람과 공유합니다. 모든 사람의 채팅이 내 요금제 한도에서 차감됩니다. 요금제와 OpenAI 약관에서 허용되는지 확인하세요.', diff --git a/shared/src/i18n/nl/assistant.ts b/shared/src/i18n/nl/assistant.ts index e26a8adea..beee13a64 100644 --- a/shared/src/i18n/nl/assistant.ts +++ b/shared/src/i18n/nl/assistant.ts @@ -115,6 +115,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Boeking lezen', 'assistant.step.planningHike': 'Wandeling plannen', 'assistant.step.planningHikeTo': 'Plant de wandeling naar {place}', + 'assistant.step.expenses': 'De uitgaven lezen', + 'assistant.step.shares': 'De aandelen berekenen', + 'assistant.step.wind': 'De wind controleren', + 'assistant.step.tracks': 'De GPX-tracks lezen', + 'assistant.step.bucketScan': 'Je wensenlijst naast je opnames leggen', + 'assistant.step.atlasCountries': 'Landen zoeken in je opnames', 'assistant.step.working': 'Bezig', 'assistant.step.collapsed': '{count} stappen', 'assistant.step.collapsed.one': '{count} stap', @@ -437,6 +443,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Vervoer toevoegen', 'assistant.tool.toggle_vacay_entry': 'Vakantiedag instellen', 'assistant.tool.mark_country_visited': 'Land als bezocht markeren', + 'assistant.tool.confirm_bucket_list_visits': 'Afvinken op de wensenlijst', + 'assistant.tool.clear_bucket_list_visit': 'Vinkje op de wensenlijst weghalen', + 'assistant.tool.accept_atlas_countries': 'Landen aan de Atlas toevoegen', 'assistant.tool.send_collab_message': 'Naar de reischat sturen', 'assistant.admin.chatgptShared': 'Je deelt je eigen ChatGPT-abonnement met iedereen op deze server: hun chats tellen mee voor jouw abonnement. Controleer of je abonnement en de voorwaarden van OpenAI dit toestaan.', diff --git a/shared/src/i18n/pl/assistant.ts b/shared/src/i18n/pl/assistant.ts index 14d914df2..575068301 100644 --- a/shared/src/i18n/pl/assistant.ts +++ b/shared/src/i18n/pl/assistant.ts @@ -116,6 +116,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Czytam rezerwację', 'assistant.step.planningHike': 'Planuję wędrówkę', 'assistant.step.planningHikeTo': 'Planuje wędrówkę do celu: {place}', + 'assistant.step.expenses': 'Czytam wydatki', + 'assistant.step.shares': 'Liczę udziały', + 'assistant.step.wind': 'Sprawdzam wiatr', + 'assistant.step.tracks': 'Czytam ślady GPX', + 'assistant.step.bucketScan': 'Porównuję listę marzeń z Twoimi zapisami', + 'assistant.step.atlasCountries': 'Szukam krajów w Twoich zapisach', 'assistant.step.working': 'Pracuję nad tym', 'assistant.step.collapsed': '{count} kroku', 'assistant.step.collapsed.one': '{count} krok', @@ -479,6 +485,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Dodaj transport', 'assistant.tool.toggle_vacay_entry': 'Ustaw dzień urlopu', 'assistant.tool.mark_country_visited': 'Oznacz kraj jako odwiedzony', + 'assistant.tool.confirm_bucket_list_visits': 'Odhacz na liście marzeń', + 'assistant.tool.clear_bucket_list_visit': 'Cofnij odhaczenie na liście marzeń', + 'assistant.tool.accept_atlas_countries': 'Dodaj kraje do Atlasu', 'assistant.tool.send_collab_message': 'Wyślij na czat podróży', 'assistant.admin.chatgptShared': 'Udostępniasz własny plan ChatGPT wszystkim na tym serwerze: ich czaty liczą się do twojego planu. Sprawdź, czy twój plan i warunki OpenAI na to pozwalają.', diff --git a/shared/src/i18n/ru/assistant.ts b/shared/src/i18n/ru/assistant.ts index 12f4c433b..34990e8ca 100644 --- a/shared/src/i18n/ru/assistant.ts +++ b/shared/src/i18n/ru/assistant.ts @@ -116,6 +116,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Читаю бронирование', 'assistant.step.planningHike': 'Планирую поход', 'assistant.step.planningHikeTo': 'Планирую поход, цель: {place}', + 'assistant.step.expenses': 'Читаю расходы', + 'assistant.step.shares': 'Считаю доли', + 'assistant.step.wind': 'Проверяю ветер', + 'assistant.step.tracks': 'Читаю GPX-треки', + 'assistant.step.bucketScan': 'Сверяю список желаний с вашими записями', + 'assistant.step.atlasCountries': 'Ищу страны в ваших записях', 'assistant.step.working': 'Работаю над этим', 'assistant.step.collapsed': '{count} шага', 'assistant.step.collapsed.one': '{count} шаг', @@ -479,6 +485,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Добавить транспорт', 'assistant.tool.toggle_vacay_entry': 'Отметить день отпуска', 'assistant.tool.mark_country_visited': 'Отметить страну как посещённую', + 'assistant.tool.confirm_bucket_list_visits': 'Отметить в списке желаний', + 'assistant.tool.clear_bucket_list_visit': 'Снять отметку в списке желаний', + 'assistant.tool.accept_atlas_countries': 'Добавить страны в Атлас', 'assistant.tool.send_collab_message': 'Отправить в чат поездки', 'assistant.admin.chatgptShared': 'Вы делитесь своей подпиской ChatGPT со всеми на этом сервере: их чаты расходуют ваш лимит. Проверьте, что ваш тариф и условия OpenAI это допускают.', diff --git a/shared/src/i18n/sk/assistant.ts b/shared/src/i18n/sk/assistant.ts index ef9fe83b2..cd57c611b 100644 --- a/shared/src/i18n/sk/assistant.ts +++ b/shared/src/i18n/sk/assistant.ts @@ -115,6 +115,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Čítam rezerváciu', 'assistant.step.planningHike': 'Plánujem túru', 'assistant.step.planningHikeTo': 'Plánujem túru do cieľa {place}', + 'assistant.step.expenses': 'Čítam výdavky', + 'assistant.step.shares': 'Počítam podiely', + 'assistant.step.wind': 'Pozerám vietor', + 'assistant.step.tracks': 'Čítam GPX stopy', + 'assistant.step.bucketScan': 'Porovnávam zoznam prianí s vašimi záznamami', + 'assistant.step.atlasCountries': 'Hľadám krajiny vo vašich záznamoch', 'assistant.step.working': 'Pracujem na tom', 'assistant.step.collapsed': '{count} krokov', 'assistant.step.collapsed.one': '{count} krok', @@ -455,6 +461,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Pridať dopravu', 'assistant.tool.toggle_vacay_entry': 'Nastaviť deň dovolenky', 'assistant.tool.mark_country_visited': 'Označiť krajinu ako navštívenú', + 'assistant.tool.confirm_bucket_list_visits': 'Odškrtnúť v zozname prianí', + 'assistant.tool.clear_bucket_list_visit': 'Zrušiť odškrtnutie v zozname prianí', + 'assistant.tool.accept_atlas_countries': 'Pridať krajiny do Atlasu', 'assistant.tool.send_collab_message': 'Poslať do chatu cesty', 'assistant.admin.chatgptShared': 'Zdieľaš svoje vlastné predplatné ChatGPT so všetkými na tomto serveri: ich chaty sa počítajú do tvojho plánu. Over, že to tvoj plán a podmienky OpenAI dovoľujú.', diff --git a/shared/src/i18n/sv/assistant.ts b/shared/src/i18n/sv/assistant.ts index 12a915317..f60b57431 100644 --- a/shared/src/i18n/sv/assistant.ts +++ b/shared/src/i18n/sv/assistant.ts @@ -116,6 +116,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Läser bokningen', 'assistant.step.planningHike': 'Planerar vandringen', 'assistant.step.planningHikeTo': 'Planerar vandringen till {place}', + 'assistant.step.expenses': 'Läser utgifterna', + 'assistant.step.shares': 'Räknar ut andelarna', + 'assistant.step.wind': 'Kollar vinden', + 'assistant.step.tracks': 'Läser GPX-spåren', + 'assistant.step.bucketScan': 'Stämmer av önskelistan mot dina inspelningar', + 'assistant.step.atlasCountries': 'Letar efter länder i dina inspelningar', 'assistant.step.working': 'Arbetar på det', 'assistant.step.collapsed': '{count} steg', 'assistant.step.collapsed.one': '{count} steg', @@ -435,6 +441,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Lägg till transport', 'assistant.tool.toggle_vacay_entry': 'Sätt en semesterdag', 'assistant.tool.mark_country_visited': 'Markera landet som besökt', + 'assistant.tool.confirm_bucket_list_visits': 'Bocka av på önskelistan', + 'assistant.tool.clear_bucket_list_visit': 'Ta bort bocken på önskelistan', + 'assistant.tool.accept_atlas_countries': 'Lägg till länderna i Atlas', 'assistant.tool.send_collab_message': 'Skicka till reschatten', 'assistant.admin.chatgptShared': 'Du delar ditt eget ChatGPT-abonnemang med alla på den här servern: deras chattar räknas mot ditt abonnemang. Kontrollera att ditt abonnemang och OpenAIs villkor tillåter det.', diff --git a/shared/src/i18n/th/assistant.ts b/shared/src/i18n/th/assistant.ts index 9247eb83c..fb766add9 100644 --- a/shared/src/i18n/th/assistant.ts +++ b/shared/src/i18n/th/assistant.ts @@ -111,6 +111,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'กำลังอ่านการจอง', 'assistant.step.planningHike': 'กำลังวางแผนเดินป่า', 'assistant.step.planningHikeTo': 'กำลังวางแผนเดินป่าไปยัง {place}', + 'assistant.step.expenses': 'กำลังอ่านรายจ่าย', + 'assistant.step.shares': 'กำลังคำนวณส่วนของแต่ละคน', + 'assistant.step.wind': 'กำลังดูลม', + 'assistant.step.tracks': 'กำลังอ่านเส้นทาง GPX', + 'assistant.step.bucketScan': 'กำลังตรวจสอบรายการอยากไปกับบันทึกของคุณ', + 'assistant.step.atlasCountries': 'กำลังหาประเทศจากบันทึกของคุณ', 'assistant.step.working': 'กำลังดำเนินการ', 'assistant.step.collapsed': '{count} ขั้นตอน', 'assistant.change.title': 'การเปลี่ยนแปลงที่แนะนำ', @@ -408,6 +414,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'เพิ่มการเดินทาง', 'assistant.tool.toggle_vacay_entry': 'กำหนดวันลา', 'assistant.tool.mark_country_visited': 'ทำเครื่องหมายว่าเคยไปประเทศนี้', + 'assistant.tool.confirm_bucket_list_visits': 'ทำเครื่องหมายว่าสำเร็จในรายการอยากไป', + 'assistant.tool.clear_bucket_list_visit': 'ยกเลิกเครื่องหมายสำเร็จในรายการอยากไป', + 'assistant.tool.accept_atlas_countries': 'เพิ่มประเทศลงในแผนที่โลก', 'assistant.tool.send_collab_message': 'ส่งไปยังแชตทริป', 'assistant.admin.chatgptShared': 'คุณกำลังแชร์แพ็กเกจ ChatGPT ของตัวเองกับทุกคนบนเซิร์ฟเวอร์นี้: แชตของทุกคนจะนับรวมในแพ็กเกจของคุณ ตรวจสอบว่าแพ็กเกจและข้อกำหนดของ OpenAI อนุญาต', diff --git a/shared/src/i18n/tr/assistant.ts b/shared/src/i18n/tr/assistant.ts index 6626c064e..4e64cdbaf 100644 --- a/shared/src/i18n/tr/assistant.ts +++ b/shared/src/i18n/tr/assistant.ts @@ -114,6 +114,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Rezervasyon okunuyor', 'assistant.step.planningHike': 'Yürüyüş planlanıyor', 'assistant.step.planningHikeTo': '{place} için yürüyüş planlanıyor', + 'assistant.step.expenses': 'Harcamalar okunuyor', + 'assistant.step.shares': 'Paylar hesaplanıyor', + 'assistant.step.wind': 'Rüzgar kontrol ediliyor', + 'assistant.step.tracks': 'GPX izleri okunuyor', + 'assistant.step.bucketScan': 'Dilek listesi kayıtlarınızla karşılaştırılıyor', + 'assistant.step.atlasCountries': 'Kayıtlarınızda ülkeler aranıyor', 'assistant.step.working': 'Üzerinde çalışılıyor', 'assistant.step.collapsed': '{count} adım', 'assistant.step.collapsed.one': '{count} adım', @@ -435,6 +441,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Ulaşım ekle', 'assistant.tool.toggle_vacay_entry': 'İzin günü belirle', 'assistant.tool.mark_country_visited': 'Ülkeyi ziyaret edildi olarak işaretle', + 'assistant.tool.confirm_bucket_list_visits': 'Dilek listesinde işaretle', + 'assistant.tool.clear_bucket_list_visit': 'Dilek listesindeki işareti kaldır', + 'assistant.tool.accept_atlas_countries': 'Ülkeleri Atlas’a ekle', 'assistant.tool.send_collab_message': 'Gezi sohbetine gönder', 'assistant.admin.chatgptShared': 'Kendi ChatGPT aboneliğini bu sunucudaki herkesle paylaşıyorsun: sohbetleri senin planından düşer. Planının ve OpenAI koşullarının buna izin verip vermediğini kontrol et.', diff --git a/shared/src/i18n/uk/assistant.ts b/shared/src/i18n/uk/assistant.ts index 7304d2eac..dd9cbbc20 100644 --- a/shared/src/i18n/uk/assistant.ts +++ b/shared/src/i18n/uk/assistant.ts @@ -116,6 +116,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Читаю бронювання', 'assistant.step.planningHike': 'Планую похід', 'assistant.step.planningHikeTo': 'Планую похід, ціль: {place}', + 'assistant.step.expenses': 'Читаю витрати', + 'assistant.step.shares': 'Рахую частки', + 'assistant.step.wind': 'Перевіряю вітер', + 'assistant.step.tracks': 'Читаю GPX-треки', + 'assistant.step.bucketScan': 'Звіряю список бажань із вашими записами', + 'assistant.step.atlasCountries': 'Шукаю країни у ваших записах', 'assistant.step.working': 'Працюю над цим', 'assistant.step.collapsed': '{count} кроку', 'assistant.step.collapsed.one': '{count} крок', @@ -477,6 +483,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Додати транспорт', 'assistant.tool.toggle_vacay_entry': 'Позначити день відпустки', 'assistant.tool.mark_country_visited': 'Позначити країну як відвідану', + 'assistant.tool.confirm_bucket_list_visits': 'Позначити виконаним у списку бажань', + 'assistant.tool.clear_bucket_list_visit': 'Зняти позначку в списку бажань', + 'assistant.tool.accept_atlas_countries': 'Додати країни до Атласу', 'assistant.tool.send_collab_message': 'Надіслати в чат подорожі', 'assistant.admin.chatgptShared': 'Ви ділитеся своєю підпискою ChatGPT з усіма на цьому сервері: їхні чати витрачають ваш ліміт. Перевірте, що ваш тариф і умови OpenAI це дозволяють.', diff --git a/shared/src/i18n/vi/assistant.ts b/shared/src/i18n/vi/assistant.ts index 3ec939743..f73851c26 100644 --- a/shared/src/i18n/vi/assistant.ts +++ b/shared/src/i18n/vi/assistant.ts @@ -113,6 +113,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': 'Đang đọc đặt chỗ', 'assistant.step.planningHike': 'Đang lên kế hoạch đi bộ', 'assistant.step.planningHikeTo': 'Đang lên kế hoạch đi bộ đến {place}', + 'assistant.step.expenses': 'Đang đọc các khoản chi', + 'assistant.step.shares': 'Đang tính phần của mỗi người', + 'assistant.step.wind': 'Đang xem gió', + 'assistant.step.tracks': 'Đang đọc các tuyến GPX', + 'assistant.step.bucketScan': 'Đang đối chiếu danh sách mong muốn với bản ghi của bạn', + 'assistant.step.atlasCountries': 'Đang tìm các quốc gia trong bản ghi của bạn', 'assistant.step.working': 'Đang xử lý', 'assistant.step.collapsed': '{count} bước', 'assistant.change.title': 'Thay đổi được đề xuất', @@ -411,6 +417,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': 'Thêm phương tiện', 'assistant.tool.toggle_vacay_entry': 'Đặt ngày nghỉ phép', 'assistant.tool.mark_country_visited': 'Đánh dấu quốc gia đã đến', + 'assistant.tool.confirm_bucket_list_visits': 'Đánh dấu hoàn thành trong danh sách mong muốn', + 'assistant.tool.clear_bucket_list_visit': 'Bỏ đánh dấu trong danh sách mong muốn', + 'assistant.tool.accept_atlas_countries': 'Thêm các quốc gia vào Atlas', 'assistant.tool.send_collab_message': 'Gửi vào trò chuyện chuyến đi', 'assistant.admin.chatgptShared': 'Bạn đang chia sẻ gói ChatGPT của riêng mình với mọi người trên máy chủ này: các cuộc trò chuyện của họ được tính vào gói của bạn. Hãy kiểm tra gói của bạn và điều khoản của OpenAI có cho phép không.', diff --git a/shared/src/i18n/zh-TW/assistant.ts b/shared/src/i18n/zh-TW/assistant.ts index 03574ceeb..1082355ec 100644 --- a/shared/src/i18n/zh-TW/assistant.ts +++ b/shared/src/i18n/zh-TW/assistant.ts @@ -110,6 +110,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': '正在讀取預訂', 'assistant.step.planningHike': '正在規劃健行路線', 'assistant.step.planningHikeTo': '正在規劃前往{place}的健行路線', + 'assistant.step.expenses': '正在讀取支出', + 'assistant.step.shares': '正在計算各人分攤', + 'assistant.step.wind': '正在查看風況', + 'assistant.step.tracks': '正在讀取 GPX 軌跡', + 'assistant.step.bucketScan': '正在用你的記錄核對願望清單', + 'assistant.step.atlasCountries': '正在你的記錄中尋找到過的國家', 'assistant.step.working': '處理中', 'assistant.step.collapsed': '{count} 個步驟', 'assistant.change.title': '建議的變更', @@ -405,6 +411,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': '新增交通', 'assistant.tool.toggle_vacay_entry': '設為休假日', 'assistant.tool.mark_country_visited': '標記國家為已去過', + 'assistant.tool.confirm_bucket_list_visits': '在願望清單中打勾', + 'assistant.tool.clear_bucket_list_visit': '取消願望清單中的打勾', + 'assistant.tool.accept_atlas_countries': '將國家新增到地圖集', 'assistant.tool.send_collab_message': '傳送到行程聊天', 'assistant.admin.chatgptShared': '你正在與此伺服器上的所有人共用你自己的 ChatGPT 訂閱:他們的聊天會計入你的方案。請確認你的方案和 OpenAI 的條款允許這樣做。', diff --git a/shared/src/i18n/zh/assistant.ts b/shared/src/i18n/zh/assistant.ts index 58c13b39a..4f137eec1 100644 --- a/shared/src/i18n/zh/assistant.ts +++ b/shared/src/i18n/zh/assistant.ts @@ -110,6 +110,12 @@ const assistant: TranslationStrings = { 'assistant.step.readingBooking': '正在读取预订', 'assistant.step.planningHike': '正在规划徒步路线', 'assistant.step.planningHikeTo': '正在规划前往{place}的徒步路线', + 'assistant.step.expenses': '正在读取支出', + 'assistant.step.shares': '正在计算各人分摊', + 'assistant.step.wind': '正在查看风况', + 'assistant.step.tracks': '正在读取 GPX 轨迹', + 'assistant.step.bucketScan': '正在用你的记录核对愿望清单', + 'assistant.step.atlasCountries': '正在你的记录中查找去过的国家', 'assistant.step.working': '正在处理', 'assistant.step.collapsed': '{count} 个步骤', 'assistant.change.title': '建议的更改', @@ -405,6 +411,9 @@ const assistant: TranslationStrings = { 'assistant.tool.create_transport': '添加交通', 'assistant.tool.toggle_vacay_entry': '设为休假日', 'assistant.tool.mark_country_visited': '标记国家为已去过', + 'assistant.tool.confirm_bucket_list_visits': '在愿望清单中勾掉', + 'assistant.tool.clear_bucket_list_visit': '撤销愿望清单中的勾选', + 'assistant.tool.accept_atlas_countries': '将国家添加到地图集', 'assistant.tool.send_collab_message': '发送到行程聊天', 'assistant.admin.chatgptShared': '你正在与此服务器上的所有人共享你自己的 ChatGPT 订阅:他们的聊天会计入你的套餐。请确认你的套餐和 OpenAI 的条款允许这样做。', diff --git a/wiki/Dawarich.md b/wiki/Dawarich.md index 0d062d338..79e9d32a9 100644 --- a/wiki/Dawarich.md +++ b/wiki/Dawarich.md @@ -175,6 +175,11 @@ With the [MCP](MCP-Overview) addon on as well, an assistant can review your stay | `accept_dawarich_suggestion_as_place` | Turns a stay into a place on a trip, optionally on one of its days, with a corrected name, notes or coordinates. | `places:write` | | `accept_dawarich_suggestion_as_journal_entry` | Turns a stay into a dated journal entry, with a title, a story, a date and a time. | `journey:write` | | `mark_bucket_list_item_visited_from_dawarich` | Ticks off the wish a stay was linked to, or another wish of yours. | `atlas:write` | +| `scan_bucket_list_visits` | Checks your wishlist against your recordings, as **Check wishlist** does: per wish the longest stay that counts, or none. Ticks nothing off. If your Dawarich does not answer, the check stops with that error. | `atlas:read` | +| `confirm_bucket_list_visits` | Ticks off the wishes you confirmed, dated with the stay that matched, or a date you name. | `atlas:write` | +| `clear_bucket_list_visit` | Takes the tick off one of your wishes again. | `atlas:write` | +| `suggest_atlas_countries` | Lists the countries and cities your recordings cover between two dates (at most 400 days apart) and which of them your Atlas has already. Marks nothing. | `atlas:read` | +| `accept_atlas_countries` | Marks the countries you confirmed as visited in your Atlas, recorded as coming from Dawarich. | `atlas:write` | | `dismiss_dawarich_suggestion` | Dismisses a stay, or puts a dismissed one back. | `journey:write` | | `get_dawarich_trip_track` | Summarises a trip's recorded route per day: segments, times, travel mode and distance. The line itself stays on the map. | `journey:read` | | `preview_journey_recorded_day` | Reads one day of your recording and says what a journal would keep, without keeping it. Needs the Journey addon too. | `journey:read` | diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index 67deaf9f9..c5e567e74 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -184,6 +184,7 @@ Requires `journey:read` or `journey:write` scope. | `list_journeys` | List all journeys owned or contributed to by the current user. | | `get_journey` | Get a full snapshot of a journey — metadata, entries, contributors, and linked trips. | | `get_journey_map_layers` | What a journey's map shows beside its entries and photos: the planned places of its linked trips with their days and the entry written about each (a plan is no proof of a visit), the trips' GPX routes by name and size while the track switch is on, and the stays journal entries were taken over from, each where it was recorded. Every source answers on its own, `ok` or `unavailable`. | +| `get_journey_tracks` | The GPX tracks a journey's map draws, from the trips added to it: per track the place and trip it belongs to, its name, its kind, how many points it has and its length in km, plus `display_geometry`, the line thinned for drawing to at most 200 points per track and about 1000 for all tracks together, both ends always kept. Same journey access as the map. | | `get_journey_stats` | 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. Entries switched off with `stats_excluded` count towards none of those and are listed under `excluded`. Pass `include_route` for the route itself, up to 400 stops with coordinates. | | `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. | | `update_journey` | Update a journey's title, subtitle, cover or status, and whether its entries offer a pros/cons list, a mood and a weather note (`show_verdict`, `show_mood`, `show_weather`). `status_override` sets the shown status by hand (`draft`, `live` or `completed`), `null` goes back to following the trip dates. `photo_location` lets an entry without a place take the position of its first geotagged photo. `auto_prepare_recordings` switches the preparation of its recorded days from Dawarich on or off. Owner only. | @@ -223,6 +224,11 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T | `mark_bucket_list_item_visited_from_dawarich` | Tick off the bucket list wish a stay was matched to, or another wish of the caller's | `atlas:write` | | `dismiss_dawarich_suggestion` | Dismiss a stay, or put a dismissed one back | `journey:write` | | `get_dawarich_trip_track` | Summary of a trip's recorded route per local day: segments, times, travel mode, distance and point counts, without the geometry. Read live from Dawarich, nothing stored | `journey:read` | +| `scan_bucket_list_visits` | Ask the recordings, wish by wish, whether the caller reached the places on their bucket list: a stay of at least 20 minutes within 250 m counts, reported with its start, length, distance and points. At most 50 wishes per scan; an instance that cannot be reached or turns the key away ends the scan with that error rather than a list of misses; nothing is ticked off | `atlas:read` | +| `confirm_bucket_list_visits` | Tick off 1 to 200 wishes after a scan, dated with `visitedAt`, else the matched stay, else today; a wish ticked off already keeps its date. Answers `updated` | `atlas:write` | +| `clear_bucket_list_visit` | Take the visited mark off one of the caller's wishes again, with its date and source | `atlas:write` | +| `suggest_atlas_countries` | The countries, cities and minutes the recordings put the caller in between `from` and `to` (at most 400 days), each marked when the Atlas has it already, plus the country names that matched no code. Read live from Dawarich; nothing is marked | `atlas:read` | +| `accept_atlas_countries` | Mark 1 to 300 confirmed countries (ISO 3166-1 alpha-2) as visited in the caller's Atlas; one marked already keeps its source. Answers `marked` | `atlas:write` | | `preview_journey_recorded_day` | Read one local day of the caller's recording and show what a journey would keep, without keeping it: the window the day runs in, distance, pieces, gaps, modes, whether the journey keeps the day already and how many other people would see it, and the `contentHash` the save needs. Needs the Journey addon too and edit access to the journey | `journey:read` | | `save_journey_recorded_day` | Keep that day in the journey after the traveller confirmed the preview. Send its `utcOffsetMinutes` and `contentHash` as `expectedHash`; a recording that changed since is not kept. A different version is replaced only with `replace: true`; entries and texts are never touched. Needs the Journey addon too | `journey:write` | | `review_journey_day_stays` | The caller's stays of one local day, read from Dawarich again, as a journey would take them over: name, place, arrival and departure, whether Dawarich only guessed it, and whether the journey holds it already, left it out before or it went elsewhere, plus the entries the journey has on that day. When Dawarich does not answer, the stays of the last sync with the reason. Needs the Journey addon too and edit access to the journey | `journey:read` | diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index e2046de56..705572151 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -155,6 +155,8 @@ Requires `budget:read` or `budget:write` scope. The Budget addon must be enabled | Tool | Description | |---|---| +| `list_budget_items` | The trip's expenses with their ids, a page at a time (`offset`, `limit` up to 100, 50 by default). A page of long expenses ends earlier, so a client goes on at `next_offset` until it is `null`. Per expense: name, category, price and currency, date, the start of its note, the linked place or booking, who paid how much and who shares it. Next to the page the trip currency, the total and the totals per category over all expenses, and `unconverted_item_ids` for foreign expenses no rate converts. | +| `get_budget_per_person` | What each person's shares of the expenses add up to and how much of that they paid, in the trip currency, as the per-person summary in the app shows it. | | `create_budget_item` | Add an expense with name, category, and price. | | `update_budget_item` | Update an expense's details, split (persons/days), or notes. `reservation_id` / `place_id` link it to a booking or place of the same trip; `null` unlinks it. | | `delete_budget_item` | Remove a budget item. | @@ -193,6 +195,7 @@ Requires `places:read` or `places:write` scope. | `get_airport` | `geo:read` | Look up an airport by IATA code (e.g. `"ZRH"`, `"CDG"`). | | `get_weather` | `weather:read` | Get a weather forecast for a location and date. | | `get_detailed_weather` | `weather:read` | Get an hourly/detailed weather forecast for a location and date. | +| `get_wind_forecast` | `weather:read` | Get the wind for a location on one day: per local hour the mean speed and the gusts in km/h and the direction it comes from, and the day's strongest mean, strongest gust and dominant direction. Only a forecast: a past day or one more than 16 days ahead answers its status without figures. | ### Notifications