Compare commits

...

5 Commits

Author SHA1 Message Date
Maurice e79d0996ae Merge branches 'fix/mcp-trip-default-currency' and 'fix/mobile-reservation-detail-slot' into test/dev1-mcp-mobile 2026-09-22 09:59:05 +02:00
Maurice 8936049a81 fix(client): take the reported version as one anchored match before storing it
The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this
one line: the version from /api/config is written to localStorage, and the
taint engine did not read the replace() plus equality check as sanitisation
(S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters
and length as before, in the shape the engine recognises.
2026-09-22 09:56:13 +02:00
Maurice c0b691b8e5 fix(mobile): reservation-detail widgets mount on the phone's booking cards
A plugin declaring the reservation-detail slot mounted at the foot of every
card in the desktop ReservationsPanel and nowhere else: the phone's transport
and booking cards never rendered the slot, so on a phone in portrait the
reservation was there and the widget was not, and turning the phone sideways
made it appear. ReservationPluginSlots mounts one PluginFrame per widget under
each card, scoped to that reservation with surface detail-slot, outside the
card's body button and gone in compact mode.

Closes #2440
2026-09-22 09:56:13 +02:00
Maurice aa64572bf0 fix(mcp): refuse an argument a tool never declared instead of dropping it
The SDK wraps a raw shape in a plain z.object, which strips an undeclared
key and runs the handler as if nothing was wrong: an assistant that wrote
stopType for stop_type was told the place was updated while nothing had
changed. The registry registers every shape as z.strictObject, so the call
answers "Unrecognized key" and tools/list advertises additionalProperties
false; the nested objects authored inline in the tool shapes are strict
for the same reason. A whole Zod schema, the way a plugin's manifest-built
tool arrives, passes through as its author wrote it.
2026-09-22 09:55:45 +02:00
Maurice 23aec0d898 fix(trips): a trip created without a currency takes the display currency
The service wrote `data.currency || 'EUR'`, and only the trip dialog ever
sends a currency, pre-filled from the display currency in Settings. MCP
create_trip and the plugin RPC send none, so every trip an assistant created
was a EUR trip whatever the person reads amounts in. The service now asks
SettingsService for the owner's display currency, admin default merged in,
and falls back to EUR only when that is left on "Trip currency". The REST
route inherits the fallback; the web client always sends the field, so it
sees no change.
2026-09-22 09:55:33 +02:00
29 changed files with 305 additions and 46 deletions
+7 -8
View File
@@ -320,14 +320,13 @@ export default function App() {
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// A version is a short release tag and nothing else. It arrives over the // A version is a short release tag and nothing else. It arrives over the
// wire and is written to this device's storage, so it is rebuilt from the // wire and is written to this device's storage, so only a value made of
// characters a tag may contain and kept only when nothing had to be // the characters a tag may contain is taken, as the match itself. Anything
// stripped. Anything else is ignored, which also keeps a malformed value // else is ignored, which also keeps a malformed value from being compared
// from being compared against the stored marker and starting an update on // against the stored marker and starting an update on every launch.
// every launch. const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
const reportedVersion = typeof config?.version === 'string' ? config.version : '' if (releaseTag) {
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64) const version = releaseTag[0]
if (version && version === reportedVersion) {
const storedVersion = localStorage.getItem('trek_app_version') const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker // Record the version BEFORE acting on it. The old code wrote the marker
// after the purge and outside its try, so a throwing setItem (private // after the purge and outside its try, so a throwing setItem (private
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel' import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel' import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -262,6 +262,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' import MConfirmSheet from '../../settings/MConfirmSheet'
import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { import {
TRANSPORT_TYPE_COLOR, TRANSPORT_TYPE_COLOR,
@@ -280,6 +280,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
import type { ReservationTraveler } from '@trek/shared' import type { ReservationTraveler } from '@trek/shared'
import { avatarSrc } from '../../../../utils/avatarSrc' import { avatarSrc } from '../../../../utils/avatarSrc'
import GuestBadge from '../../../../components/shared/GuestBadge' import GuestBadge from '../../../../components/shared/GuestBadge'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
import { usePluginStore } from '../../../../store/pluginStore'
/** /**
* Scroll body shared by the list-style trip tabs (transports, bookings, costs, * Scroll body shared by the list-style trip tabs (transports, bookings, costs,
@@ -120,6 +122,36 @@ export function StatusDot({ color }: { color: string }) {
return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} /> return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />
} }
/**
* Plugins that declared a reservation-detail slot, mounted at the foot of a
* transport / booking card and scoped to that reservation: the phone half of
* what ReservationsPanel does on the desktop, which the phone cards never had,
* so a widget like Flight Tracker only appeared once the viewport crossed the
* desktop breakpoint (#2440). Sits outside the card's body button (buttons
* cannot nest, and a tap in the frame must not open the detail sheet), and the
* frame follows the host colour scheme like the desktop detail slots do, so a
* kit plugin that goes dark with TREK keeps compositing transparently.
*/
export function ReservationPluginSlots({ tripId, reservationId }: { tripId: number; reservationId: number }) {
const plugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'reservation-detail')
if (plugins.length === 0) return null
return (
<div className="flex flex-col gap-2 px-3 pb-3">
{plugins.map(p => (
<div key={p.id} className="overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card">
<PluginFrame
pluginId={p.id}
tripId={String(tripId)}
reservationId={String(reservationId)}
title={p.name}
surface="detail-slot"
/>
</div>
))}
</div>
)
}
/** /**
* Read-only avatar-chip cluster for a reservation's assigned travelers (#1517). * Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
* The mobile transport / booking cards only display them — assignment editing * The mobile transport / booking cards only display them — assignment editing
@@ -3,17 +3,27 @@ import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018 // FE-MOB-BKTAB-001 to FE-MOB-BKTAB-023
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const SEAT_MAP = { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -93,6 +103,7 @@ function cardOf(title: string): HTMLElement {
describe('MBookingsTab', () => { describe('MBookingsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => { it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => {
@@ -285,4 +296,29 @@ describe('MBookingsTab', () => {
// the check-in cell and the time field both read 09:00 // the check-in cell and the time field both read 09:00
expect(within(card).getAllByText('09:00')).toHaveLength(2) expect(within(card).getAllByText('09:00')).toHaveLength(2)
}) })
it('FE-MOB-BKTAB-022: mounts a reservation-detail plugin on every booking card, scoped to that booking (#2440)', () => {
seedStore(usePluginStore, {
plugins: [SEAT_MAP, { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'sidebar' }],
})
const { planner: p } = renderTab()
const frame = within(cardOf('Hotel Granvia')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'seat-map')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '201')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no editor
fireEvent.click(frame)
expect(p.setShowReservationModal).not.toHaveBeenCalled()
expect(within(cardOf('Bamboo walk')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '204')
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(4)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
})
it('FE-MOB-BKTAB-023: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [SEAT_MAP] })
renderTab(planner(), buildShell({ bookingsCompact: true }))
expect(screen.getByText('Hotel Granvia')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
}) })
@@ -3,17 +3,27 @@ import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransports
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
import { seedStore } from '../../../helpers/store'
// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022 // FE-MOB-TRTAB-001 to FE-MOB-TRTAB-028
vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const FLIGHT_TRACKER = { id: 'flight-tracker', name: 'Flight Tracker', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' },
@@ -101,6 +111,7 @@ function cardOf(title: string): HTMLElement {
describe('MTransportsTab', () => { describe('MTransportsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => { it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => {
@@ -322,4 +333,35 @@ describe('MTransportsTab', () => {
// nobody is assigned, so the traveler filter row stays away // nobody is assigned, so the traveler filter row stays away
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument() expect(screen.queryByTitle('Ada')).not.toBeInTheDocument()
}) })
it('FE-MOB-TRTAB-027: mounts a reservation-detail plugin on every card, scoped to that reservation (#2440)', () => {
seedStore(usePluginStore, {
plugins: [
FLIGHT_TRACKER,
{ id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' },
{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'place-detail' },
],
})
const { shell } = renderTab()
const frame = within(cardOf('HND to ITM')).getByTestId('plugin-frame')
expect(frame).toHaveAttribute('data-plugin', 'flight-tracker')
expect(frame).toHaveAttribute('data-trip', '7')
expect(frame).toHaveAttribute('data-reservation', '101')
expect(frame).toHaveAttribute('data-surface', 'detail-slot')
// the slot sits outside the card's body button: a tap in it opens no sheet
fireEvent.click(frame)
expect(shell.openSheet).not.toHaveBeenCalled()
expect(within(cardOf('Metro to hotel')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '104')
// one frame per transport card, and only for the reservation-detail slot
expect(screen.getAllByTestId('plugin-frame')).toHaveLength(5)
expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull()
expect(document.querySelector('[data-plugin="seat-map"]')).toBeNull()
})
it('FE-MOB-TRTAB-028: compact mode drops the plugin frames with the rest of the body', () => {
seedStore(usePluginStore, { plugins: [FLIGHT_TRACKER] })
renderTab(planner(), buildShell({ transportsCompact: true }))
expect(screen.getByText('HND to ITM')).toBeInTheDocument()
expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument()
})
}) })
+2 -2
View File
@@ -24,7 +24,7 @@ export class ThingsMcp {
@Tool({ @Tool({
name: 'list_things', name: 'list_things',
description: 'List all things.', description: 'List all things.',
inputSchema: {}, // ZodRawShape, passed straight to the SDK inputSchema: {}, // ZodRawShape, registered as a strict object
annotations: { readOnlyHint: true }, annotations: { readOnlyHint: true },
access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy
}) })
@@ -34,7 +34,7 @@ export class ThingsMcp {
} }
``` ```
- `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared. - `@Tool(options)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared. A shape is registered as `z.strictObject`, so an argument the tool never declared is refused (`Unrecognized key`) and `tools/list` advertises `additionalProperties: false`; the SDK's own wrapping would strip it and run the handler regardless. A whole Zod schema is passed through as written.
- `@Resource(options)` — fixed URI (`uri`, `mimeType`, …). Handler: `(uri: URL, ctx)`. - `@Resource(options)` — fixed URI (`uri`, `mimeType`, …). Handler: `(uri: URL, ctx)`.
- `@ResourceTemplate(options)` — `uriTemplate` (RFC 6570). Handler: `(uri: URL, variables, ctx)`. (No `list`/`complete` template callbacks yet — extend when a domain needs them.) - `@ResourceTemplate(options)` — `uriTemplate` (RFC 6570). Handler: `(uri: URL, variables, ctx)`. (No `list`/`complete` template callbacks yet — extend when a domain needs them.)
- `@Prompt(options)` — mirrors `registerPrompt` (`argsSchema` entries must be string-valued Zod schemas). Handler: `(args, ctx)`. - `@Prompt(options)` — mirrors `registerPrompt` (`argsSchema` entries must be string-valued Zod schemas). Handler: `(args, ctx)`.
+16 -4
View File
@@ -15,12 +15,24 @@ import type {
} from './types'; } from './types';
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
interface BoundEntry { interface BoundEntry {
entry: McpEntry; entry: McpEntry;
instance: object; instance: object;
} }
/**
* A shape is registered strict: the SDK's own z.object wrapping would strip an
* undeclared key and run the handler as if nothing was wrong, and strict also
* puts `additionalProperties: false` into tools/list. A whole schema is the
* author's call and passes through untouched.
*/
function strictInputSchema(schema: ToolOptions['inputSchema']): unknown {
if (schema === undefined || '_zod' in schema) return schema;
return z.strictObject(schema);
}
export interface McpRegistryOptions { export interface McpRegistryOptions {
accessPolicy?: McpAccessPolicy; accessPolicy?: McpAccessPolicy;
validateAccess?: McpAccessValidator; validateAccess?: McpAccessValidator;
@@ -30,9 +42,9 @@ type AnyHandler = (this: unknown, ...handlerArgs: unknown[]) => unknown;
/** /**
* Structural view of the SDK registration surface. The SDK's real signatures * Structural view of the SDK registration surface. The SDK's real signatures
* are generic over the Zod shapes; the registry passes schemas straight * are generic over the Zod shapes; the registry hands schemas over as objects
* through and binds `ctx` where the SDK would pass `extra`, so it needs (and * and binds `ctx` where the SDK would pass `extra`, so it needs (and exposes)
* exposes) none of that type-level machinery. * none of that type-level machinery.
*/ */
interface LooseRegistrar { interface LooseRegistrar {
registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown; registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
@@ -294,7 +306,7 @@ export class McpRegistry {
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
title: options.title, title: options.title,
description: options.description, description: options.description,
inputSchema: options.inputSchema, inputSchema: strictInputSchema(options.inputSchema),
outputSchema: options.outputSchema, outputSchema: options.outputSchema,
annotations: options.annotations, annotations: options.annotations,
_meta: options._meta, _meta: options._meta,
+3 -2
View File
@@ -126,8 +126,9 @@ export interface McpZodSchema {
} }
/** /**
* Mirrors the SDK's `registerTool` config. `inputSchema` is passed straight * Mirrors the SDK's `registerTool` config. A shape `inputSchema` is registered
* through — the SDK does the parsing/validation. * as a strict object (see registry.ts); a whole schema is passed as written. The
* SDK does the parsing/validation either way.
* `annotations`/`_meta` are kept structural so the package's public d.ts * `annotations`/`_meta` are kept structural so the package's public d.ts
* never imports deep SDK subpaths. * never imports deep SDK subpaths.
*/ */
+2 -2
View File
@@ -25,13 +25,13 @@ const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
* signed like the REST contract's (#2176): a negative payer is the recipient * signed like the REST contract's (#2176): a negative payer is the recipient
* of a refund recorded as a negative expense. * of a refund recorded as a negative expense.
*/ */
const payersSchema = z.array(z.object({ const payersSchema = z.array(z.strictObject({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
amount: z.number(), amount: z.number(),
})).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.'); })).describe('Who actually paid, and how much each paid, in the expense currency. Ask the user; do not guess.');
/** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */ /** Reusable Zod shape for an unequal split: what each participant owes. Signed, like the REST contract (#2176). */
const splitMembersSchema = z.array(z.object({ const splitMembersSchema = z.array(z.strictObject({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
amount: z.number(), amount: z.number(),
})).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.'); })).describe('Unequal split: what each participant owes, in the expense currency. The amounts must add up to the expense total. Ask the user; do not guess.');
+1 -1
View File
@@ -63,7 +63,7 @@ type EntryVisibility = z.infer<typeof ENTRY_VISIBILITY>;
* only the half it has, which is what the entry editor does when a place was * only the half it has, which is what the entry editor does when a place was
* all good or all bad. The service stores nothing when both come back empty. * all good or all bad. The service stores nothing when both come back empty.
*/ */
const PROS_CONS = z.object({ const PROS_CONS = z.strictObject({
pros: z.array(z.string()).default([]), pros: z.array(z.string()).default([]),
cons: z.array(z.string()).default([]), cons: z.array(z.string()).default([]),
}); });
+1 -1
View File
@@ -95,7 +95,7 @@ export class MapsMcp {
z.enum(POI_CATEGORY_KEYS), z.enum(POI_CATEGORY_KEYS),
z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES), z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES),
]).describe('Which kind of place to look for. Pass several to cover them in one query rather than one request per kind'), ]).describe('Which kind of place to look for. Pass several to cover them in one query rather than one request per kind'),
bbox: z.object({ bbox: z.strictObject({
south: z.number().min(-90).max(90).describe('Southern edge, latitude'), south: z.number().min(-90).max(90).describe('Southern edge, latitude'),
west: z.number().min(-180).max(180).describe('Western edge, longitude'), west: z.number().min(-180).max(180).describe('Western edge, longitude'),
north: z.number().min(-90).max(90).describe('Northern edge, latitude'), north: z.number().min(-90).max(90).describe('Northern edge, latitude'),
+1 -1
View File
@@ -511,7 +511,7 @@ export class PackingMcp {
description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.', description: 'Import multiple packing items at once from a list. Optionally assign each to a bag (by name — created if missing), set its weight, or pre-check it.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
items: z.array(z.object({ items: z.array(z.strictObject({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
category: z.string().optional(), category: z.string().optional(),
quantity: z.number().int().positive().optional(), quantity: z.number().int().positive().optional(),
@@ -35,7 +35,7 @@ export class PluginSearchMcp {
description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.', description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.',
inputSchema: { inputSchema: {
query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'), query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'),
near: z.object({ lat: z.number(), lng: z.number() }) near: z.strictObject({ lat: z.number(), lng: z.number() })
.optional() .optional()
.describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'), .describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'),
lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'), lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'),
@@ -47,7 +47,7 @@ const urlField = reservationUrlSchema.max(2000).optional()
type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number]; type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number];
type BookingType = typeof BOOKING_TYPES[number]; type BookingType = typeof BOOKING_TYPES[number];
const endpointObjectSchema = z.object({ const endpointObjectSchema = z.strictObject({
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'), role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
sequence: z.number().int().min(0).describe('Order within the route (0-based)'), sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'), name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
@@ -520,7 +520,7 @@ export class ReservationsMcp {
description: 'Update the display order of reservations within a day.', description: 'Update the display order of reservations within a day.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
positions: z.array(z.object({ positions: z.array(z.strictObject({
id: z.number().int().positive(), id: z.number().int().positive(),
day_plan_position: z.number().int().min(0), day_plan_position: z.number().int().min(0),
})).describe('Array of { id, day_plan_position } pairs'), })).describe('Array of { id, day_plan_position } pairs'),
+3 -3
View File
@@ -89,14 +89,14 @@ export class RoadtripMcp {
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
vias: z.array(z.object({ vias: z.array(z.strictObject({
after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'), after_order_index: z.number().int().min(0).describe('Which stop of the day this via follows, counting from 0'),
lat: z.number().min(-90).max(90), lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180), lng: z.number().min(-180).max(180),
})).max(100).describe('In the order the drive passes through them'), })).max(100).describe('In the order the drive passes through them'),
replace_legs: z.array(z.number().int().min(0)).max(100).optional() replace_legs: z.array(z.number().int().min(0)).max(100).optional()
.describe('Legs to clear before inserting, by the index of the stop they follow'), .describe('Legs to clear before inserting, by the index of the stop they follow'),
track: z.object({ track: z.strictObject({
place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'), place_id: z.number().int().positive().describe('The imported track this chain was fitted to, as its place id'),
stray_km: z.number().min(0).max(40_000).nullable().optional() stray_km: z.number().min(0).max(40_000).nullable().optional()
.describe('How far the fitted route still runs from the track at its worst point'), .describe('How far the fitted route still runs from the track at its worst point'),
@@ -139,7 +139,7 @@ export class RoadtripMcp {
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
vias: z.array(z.object({ vias: z.array(z.strictObject({
id: z.number().int().positive(), id: z.number().int().positive(),
after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'), after_order_index: z.number().int().min(0).describe('Which stop of the day the via now follows, counting from 0'),
})).max(500), })).max(500),
+1 -1
View File
@@ -88,7 +88,7 @@ export class TripsMcp {
description: z.string().max(2000).optional().describe('Trip description'), description: z.string().max(2000).optional().describe('Trip description'),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'), end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'), currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD). Left out, the trip takes the display currency from the settings (see get_display_settings), or EUR when none is set.'),
day_count: z.number().int().min(1).max(MAX_TRIP_DAYS).optional().describe( day_count: z.number().int().min(1).max(MAX_TRIP_DAYS).optional().describe(
'How many days a trip without dates gets (default 7). Ignored when start_date and end_date are both set, because the range decides the count.'), 'How many days a trip without dates gets (default 7). Ignored when start_date and end_date are both set, because the range decides the count.'),
reminder_days: z.number().int().min(0).max(30).optional().describe( reminder_days: z.number().int().min(0).max(30).optional().describe(
+2 -1
View File
@@ -32,6 +32,7 @@ import { StorageModule } from '../storage/storage.module';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { buildStorageUploadOptions } from '../storage/storage-upload.factory'; import { buildStorageUploadOptions } from '../storage/storage-upload.factory';
import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller'; import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
import { SettingsModule } from '../settings/settings.module';
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does /** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
* not capture the nested sub-domain mounts (collab, files, ...). */ * not capture the nested sub-domain mounts (collab, files, ...). */
@@ -48,7 +49,7 @@ import { MAX_COVER_SIZE, TRIP_COVER_FILE_FILTER } from './trips.controller';
}), }),
}), }),
StorageModule, StorageModule,
McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule], McpSharedModule, TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule, SettingsModule],
controllers: [TripsController], controllers: [TripsController],
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc], providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection). // Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
+19 -5
View File
@@ -11,6 +11,7 @@ import { ReservationsService } from '../reservations/reservations.service';
import { VacayService } from '../vacay/vacay.service'; import { VacayService } from '../vacay/vacay.service';
import { UnsplashService } from '../unsplash/unsplash.service'; import { UnsplashService } from '../unsplash/unsplash.service';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { SettingsService } from '../settings/settings.service';
import { NotFoundError, ValidationError } from '../common/domain-errors'; import { NotFoundError, ValidationError } from '../common/domain-errors';
export const MS_PER_DAY = 86400000; export const MS_PER_DAY = 86400000;
@@ -134,15 +135,16 @@ export interface GuestMember {
/** /**
* Trip aggregate root, DI-native. Membership, the calendar export and the two * Trip aggregate root, DI-native. Membership, the calendar export and the two
* read aggregates live in their own domains now; what is left is the write core * read aggregates live in their own domains now; what is left is the write core
* plus day generation, which is why the constructor is eight parameters instead * plus day generation, which is why the constructor is far shorter than the
* of fourteen. The SQL moved 1:1 from the legacy * fourteen parameters it started with. The SQL moved 1:1 from the legacy
* services/tripService.ts: identical statements, the `||` falsy-coercion * services/tripService.ts: identical statements, the `||` falsy-coercion
* defaults, the post-write TRIP_SELECT re-selects and the mixed * defaults, the post-write TRIP_SELECT re-selects and the mixed
* named/positional parameter styles are all preserved byte-for-byte. * named/positional parameter styles are all preserved byte-for-byte.
* Post-migration quirk fixes on top of the 1:1 move: the multi-statement * Post-migration quirk fixes on top of the 1:1 move: the multi-statement
* deletes (remove, deleteGuest's re-split + user delete) run in * deletes (remove, deleteGuest's re-split + user delete) run in
* db.transaction(), and listMembers' owner row COALESCEs display_name like * db.transaction(), listMembers' owner row COALESCEs display_name like
* the member rows. Auth (canAccessTrip), per-field permission checks and * the member rows, and create() defaults the currency to the owner's display
* currency instead of the legacy EUR literal. Auth (canAccessTrip), per-field permission checks and
* audit logging stay in the controller (1:1 with the legacy route); * audit logging stay in the controller (1:1 with the legacy route);
* trip:updated / trip:deleted broadcasts stay in the controller too — this * trip:updated / trip:deleted broadcasts stay in the controller too — this
* service emits none. * service emits none.
@@ -159,12 +161,24 @@ export class TripsService {
private readonly realtime: RealtimeService, private readonly realtime: RealtimeService,
private readonly unsplash: UnsplashService, private readonly unsplash: UnsplashService,
private readonly storage: StorageService, private readonly storage: StorageService,
private readonly settings: SettingsService,
) {} ) {}
private get db() { private get db() {
return this.dbs.connection; return this.dbs.connection;
} }
/**
* The currency a trip gets when the caller names none: the person's display
* currency, admin default merged in, else EUR. The trip dialog pre-fills the
* same value on the client, so a trip created through MCP or a plugin lands
* in the currency the person reads amounts in instead of always in EUR.
*/
private defaultCurrencyFor(userId: number): string {
const preferred = this.settings.getUserSettings(userId).default_currency;
return typeof preferred === 'string' && preferred.trim() ? preferred.trim() : 'EUR';
}
canAccessTrip(tripId: string | number, userId: number) { canAccessTrip(tripId: string | number, userId: number) {
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined; return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
} }
@@ -328,7 +342,7 @@ export class TripsService {
const result = this.db.prepare(` const result = this.db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days) INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd); `).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || this.defaultCurrencyFor(userId), rd);
const tripId = result.lastInsertRowid; const tripId = result.lastInsertRowid;
this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count); this.generateDays(tripId, data.start_date || null, data.end_date || null, data.day_count);
+14
View File
@@ -101,6 +101,9 @@ const { db } = vi.hoisted(() => {
// StorageRegistryService (behind StorageModule, now in this module chain) reads // StorageRegistryService (behind StorageModule, now in this module chain) reads
// this at onModuleInit. // this at onModuleInit.
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
// A trip created without a currency takes the owner's display currency, read
// off the per-user settings rows.
tmp.exec('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));');
return { db: tmp }; return { db: tmp };
}); });
@@ -160,6 +163,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
db.prepare('DELETE FROM trip_members').run(); db.prepare('DELETE FROM trip_members').run();
db.prepare('DELETE FROM days').run(); db.prepare('DELETE FROM days').run();
db.prepare('DELETE FROM audit_log').run(); db.prepare('DELETE FROM audit_log').run();
db.prepare('DELETE FROM settings').run();
canAccessTrip.mockReturnValue({ user_id: 1 }); canAccessTrip.mockReturnValue({ user_id: 1 });
checkPermission.mockReturnValue(true); checkPermission.mockReturnValue(true);
}); });
@@ -198,6 +202,16 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
expect(forbidden.status).toBe(403); expect(forbidden.status).toBe(403);
}); });
it('201 create without a currency takes the display currency from the settings', async () => {
db.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'default_currency', ?)").run(JSON.stringify('USD'));
const preferred = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Road trip' });
expect(preferred.status).toBe(201);
expect(preferred.body.trip).toMatchObject({ title: 'Road trip', currency: 'USD' });
const explicit = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'Tokyo', currency: 'JPY' });
expect(explicit.status).toBe(201);
expect(explicit.body.trip).toMatchObject({ title: 'Tokyo', currency: 'JPY' });
});
it('201 create keeps every day of a trip longer than a year (#2403)', async () => { it('201 create keeps every day of a trip longer than a year (#2403)', async () => {
// 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365. // 2025-01-26 .. 2026-01-28 is 368 days; the day list used to stop at 365.
const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)) const res = await request(server).post('/api/trips').set('Cookie', sessionCookie(1))
@@ -186,6 +186,7 @@ export function createMcpTestRegistry(): McpRegistry {
realtimeService, realtimeService,
new UnsplashService(dbService, new RuntimeEnvService(), generalStorage), new UnsplashService(dbService, new RuntimeEnvService(), generalStorage),
generalStorage, generalStorage,
new SettingsService(dbService),
); );
const readModelService = new TripReadModelService( const readModelService = new TripReadModelService(
dbService, membersService, daysService, accommodationsService, budgetService, dbService, membersService, daysService, accommodationsService, budgetService,
+1 -1
View File
@@ -113,7 +113,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations); const places = new PlacesService(dbs, permissions, realtime, new MapsService(dbs, photoCache), queryHelpers, unsplash, photoCache, journey, generalStorage, accommodations);
// After accommodations: a hotel booking writes the stay's day stop through it. // After accommodations: a hotel booking writes the stay's day stop through it.
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations); const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations);
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage); const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, new SettingsService(dbs));
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub()); const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub());
const guards = new PluginGuards(dbs, permissions, addons); const guards = new PluginGuards(dbs, permissions, addons);
@@ -247,6 +247,23 @@ describe('Tool: update_place', () => {
}); });
}); });
it('refuses a misspelt field instead of silently updating nothing', async () => {
// An assistant wrote stopType for stop_type; the call used to come back as a
// success with the place untouched, and the road trip never got its fuel stop.
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Aral Bad Segeberg' });
await withHarness(user.id, async (h) => {
const refused = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stopType: 'fuel' } });
expect(refused.isError).toBe(true);
expect((refused.content as Array<{ text: string }>)[0].text).toMatch(/Unrecognized key.*stopType/);
expect((testDb.prepare('SELECT stop_type FROM places WHERE id = ?').get(place.id) as { stop_type: string | null }).stop_type).toBeNull();
expect(broadcastMock).not.toHaveBeenCalled();
const fixed = parseToolResult(await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, stop_type: 'fuel' } })) as any;
expect(fixed.place.stop_type).toBe('fuel');
});
});
it('sets image_url on an existing place (#37)', async () => { it('sets image_url on an existing place (#37)', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
+19
View File
@@ -204,6 +204,25 @@ describe('Tool: create_trip', () => {
expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 }); expect(testDb.prepare('SELECT COUNT(*) as c FROM trips').get()).toEqual({ c: 0 });
}); });
}); });
it('gives a trip without a currency the display currency from the settings, not EUR', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?)").run(user.id, JSON.stringify('USD'));
await withHarness(user.id, async (h) => {
const fromSettings = parseToolResult(await h.client.callTool({ name: 'create_trip', arguments: { title: 'Road trip' } })) as any;
expect(fromSettings.trip.currency).toBe('USD');
const explicit = parseToolResult(await h.client.callTool({ name: 'create_trip', arguments: { title: 'Tokyo', currency: 'JPY' } })) as any;
expect(explicit.trip.currency).toBe('JPY');
});
});
it('falls back to EUR when neither the user nor the admin set a display currency', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({ name: 'create_trip', arguments: { title: 'Plain' } })) as any;
expect(data.trip.currency).toBe('EUR');
});
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+36 -3
View File
@@ -43,6 +43,13 @@ class FixtureMcp {
return { content: [{ type: 'text', text: `wrote as ${ctx.userId}` }] }; return { content: [{ type: 'text', text: `wrote as ${ctx.userId}` }] };
} }
// A whole schema, the way a plugin's manifest-built tool arrives: its author
// decided how open it is, and the registry must not tighten it.
@Tool({ name: 'loose_tool', inputSchema: z.looseObject({ name: z.string() }) })
async looseTool(args: Record<string, unknown>, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] };
}
@Tool({ name: 'predicate_tool', access: (ctx: McpContext) => (ctx as TestCtx).allow === true }) @Tool({ name: 'predicate_tool', access: (ctx: McpContext) => (ctx as TestCtx).allow === true })
async predicateTool(args: Record<string, never>, ctx: TestCtx) { async predicateTool(args: Record<string, never>, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] }; return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] };
@@ -150,7 +157,7 @@ describe('McpRegistry.attach', () => {
it('registers everything for a ctx passing all access checks', async () => { it('registers everything for a ctx passing all access checks', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: true, allow: true }); harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: true, allow: true });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort(); const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool', 'predicate_tool', 'read_tool', 'write_tool']); expect(tools).toEqual(['loose_tool', 'open_tool', 'predicate_tool', 'read_tool', 'write_tool']);
const resources = (await harness.client.listResources()).resources.map((r) => r.name); const resources = (await harness.client.listResources()).resources.map((r) => r.name);
expect(resources).toEqual(['fixture_doc']); expect(resources).toEqual(['fixture_doc']);
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name); const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
@@ -162,13 +169,13 @@ describe('McpRegistry.attach', () => {
it('filters declarative access through the policy per mode', async () => { it('filters declarative access through the policy per mode', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: false, allow: false }); harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: false, allow: false });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort(); const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool', 'read_tool']); expect(tools).toEqual(['loose_tool', 'open_tool', 'read_tool']);
}); });
it('filters everything gated when the ctx grants nothing, keeping ungated entries', async () => { it('filters everything gated when the ctx grants nothing, keeping ungated entries', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7 }); harness = await createAttachHarness(buildRegistry(), { userId: 7 });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort(); const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool']); expect(tools).toEqual(['loose_tool', 'open_tool']);
expect((await harness.client.listResources()).resources).toEqual([]); expect((await harness.client.listResources()).resources).toEqual([]);
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name); const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
expect(templates).toEqual(['fixture_item']); expect(templates).toEqual(['fixture_item']);
@@ -188,6 +195,32 @@ describe('McpRegistry.attach', () => {
expect(JSON.parse(textOf(result))).toEqual({ greeting: 'hello trek', userId: 42 }); expect(JSON.parse(textOf(result))).toEqual({ greeting: 'hello trek', userId: 42 });
}); });
it('refuses an argument the tool never declared instead of stripping it', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42, canRead: true, canWrite: true, allow: true });
// A shape is registered strict: the SDK would otherwise drop `nmae` and run
// the handler as if the caller had sent nothing wrong.
const refused = await harness.client.callTool({ name: 'read_tool', arguments: { name: 'trek', nmae: 'typo' } });
expect(refused.isError).toBe(true);
expect(textOf(refused)).toMatch(/Unrecognized key.*nmae/);
// An empty shape is strict too: nothing declared means nothing accepted.
const stray = await harness.client.callTool({ name: 'write_tool', arguments: { anything: 1 } });
expect(stray.isError).toBe(true);
expect(textOf(stray)).toMatch(/Unrecognized key.*anything/);
expect(textOf(await harness.client.callTool({ name: 'write_tool', arguments: {} }))).toBe('wrote as 42');
// tools/list says so up front, so a client that reads the schema never sends it.
const tools = (await harness.client.listTools()).tools;
expect(tools.find((t) => t.name === 'read_tool')?.inputSchema).toMatchObject({ additionalProperties: false });
expect(tools.find((t) => t.name === 'write_tool')?.inputSchema).toMatchObject({ additionalProperties: false });
});
it('hands a whole schema to the SDK as written, however open its author left it', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42 });
const result = await harness.client.callTool({ name: 'loose_tool', arguments: { name: 'trek', extra: 'kept' } });
expect(JSON.parse(textOf(result))).toEqual({ args: { name: 'trek', extra: 'kept' }, userId: 42 });
const loose = (await harness.client.listTools()).tools.find((t) => t.name === 'loose_tool');
expect(loose?.inputSchema).not.toHaveProperty('additionalProperties', false);
});
it('normalizes args to {} for tools declared without an inputSchema', async () => { it('normalizes args to {} for tools declared without an inputSchema', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42 }); harness = await createAttachHarness(buildRegistry(), { userId: 42 });
const result = await harness.client.callTool({ name: 'open_tool', arguments: {} }); const result = await harness.client.callTool({ name: 'open_tool', arguments: {} });
@@ -88,6 +88,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { notificationsStub } from '../../helpers/notifications'; import { notificationsStub } from '../../helpers/notifications';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { SettingsService } from '../../../src/nest/settings/settings.service';
// Real sibling services over the same in-memory DB — updateTrip's date-shift // Real sibling services over the same in-memory DB — updateTrip's date-shift
// resyncs and the summary/bundle aggregation run their actual SQL. // resyncs and the summary/bundle aggregation run their actual SQL.
@@ -128,6 +129,7 @@ const svc = new TripsService(
new RealtimeService(), new RealtimeService(),
undefined as never, // unsplash — not exercised here undefined as never, // unsplash — not exercised here
coversFx.storage, coversFx.storage,
new SettingsService(dbs()),
); );
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()); const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
const readModelSvc = new TripReadModelService( const readModelSvc = new TripReadModelService(
@@ -745,6 +747,34 @@ describe('folded trip CRUD', () => {
expect(getDays(tripId)).toHaveLength(3); expect(getDays(tripId)).toHaveLength(3);
}); });
it('TRIP-SVC-069: create without a currency takes the display currency, admin default included, else EUR', () => {
const { user } = createUser(testDb);
const setUser = (value: string) =>
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'default_currency', ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value")
.run(user.id, JSON.stringify(value));
const setAdmin = (value: string) =>
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('default_user_setting_default_currency', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(JSON.stringify(value));
const currencyOf = (data: Parameters<typeof svc.create>[1]) => (svc.create(user.id, data).trip as any).currency;
expect(currencyOf({ title: 'Nothing set' })).toBe('EUR');
// "Trip currency" in the settings stores an empty string, which counts as unset.
setUser('');
expect(currencyOf({ title: 'Cleared, no admin default' })).toBe('EUR');
setUser(' ');
expect(currencyOf({ title: 'Blank, no admin default' })).toBe('EUR');
setUser('');
setAdmin('CHF');
expect(currencyOf({ title: 'Admin default' })).toBe('CHF');
setUser('USD');
expect(currencyOf({ title: 'Own display currency' })).toBe('USD');
setUser('');
expect(currencyOf({ title: 'Back on the admin default' })).toBe('CHF');
// An explicit currency always wins.
setUser('USD');
expect(currencyOf({ title: 'Explicit', currency: 'JPY' })).toBe('JPY');
});
it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', () => { it('TRIP-SVC-064: create refuses a range past MAX_TRIP_DAYS and writes nothing', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n; const before = (testDb.prepare('SELECT COUNT(*) AS n FROM trips').get() as { n: number }).n;
@@ -1193,6 +1223,7 @@ describe('quirk fixes', () => {
new RealtimeService(), new RealtimeService(),
undefined as never, undefined as never,
coversFx.storage, coversFx.storage,
new SettingsService(dbs()),
); );
} }
+1 -1
View File
@@ -12,7 +12,7 @@ The short version: **the trip currency is the accounting base, the expense curre
## Trip currency ## Trip currency
Every trip has exactly one currency. It is set when you create the trip and can be changed later in the trip edit dialog (requires the `trip_edit` permission). The new-trip dialog pre-fills it with **your display currency**, falling back to **EUR** when you have left that on *Trip currency*. This is the one moment the display currency touches stored data: it seeds the field, and from then on the trip's currency is its own. Every trip has exactly one currency. It is set when you create the trip and can be changed later in the trip edit dialog (requires the `trip_edit` permission). The new-trip dialog pre-fills it with **your display currency**, falling back to **EUR** when you have left that on *Trip currency*. A trip created through MCP or by a plugin without a currency gets the same value. This is the one moment the display currency touches stored data: it seeds the field, and from then on the trip's currency is its own.
This is the trip's **accounting base**. It is not a cosmetic label: This is the trip's **accounting base**. It is not a cosmetic label:
+3 -1
View File
@@ -2,6 +2,8 @@
TREK exposes **tools** (read and write actions) and **resources** (read-only `trek://` URIs). Tools are registered per-session based on OAuth scopes and enabled addons. TREK exposes **tools** (read and write actions) and **resources** (read-only `trek://` URIs). Tools are registered per-session based on OAuth scopes and enabled addons.
Every built-in tool checks its arguments against its published schema before anything runs. An argument the tool does not declare (a misspelt field such as `stopType` for `stop_type`) is refused with `Unrecognized key`, so the AI sees the mistake and can retry rather than reporting a change that never happened. Plugin-contributed tools follow the schema their manifest declares.
For addon-gated tools (Packing, To-Dos, Atlas, Collab, Collections, Vacay, Journey, Dawarich, Document sync, Road trip) and their resources, see [MCP-Addon-Tools](MCP-Addon-Tools). For addon-gated tools (Packing, To-Dos, Atlas, Collab, Collections, Vacay, Journey, Dawarich, Document sync, Road trip) and their resources, see [MCP-Addon-Tools](MCP-Addon-Tools).
## Tools ## Tools
@@ -31,7 +33,7 @@ Requires `trips:read` or `trips:write` scope.
| Tool | Description | | Tool | Description |
|---|---| |---|---|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. | | `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a trip with title, dates, and currency. Days are auto-generated from the date range. | | `create_trip` | Create a trip with title, dates, and currency. Days are auto-generated from the date range. Without a currency the trip takes your display currency (Settings → General, or the admin's User Defaults preset), and EUR when that is left on *Trip currency*; see [Currencies](Currencies). |
| `update_trip` | Update a trip's title, description, dates, or currency. | | `update_trip` | Update a trip's title, description, dates, or currency. |
| `search_cover_images` | Search Unsplash for candidate cover photos and return their URLs, thumbnails and photographer credits. Nothing is saved; pass the chosen photo's URL to `update_trip` as `cover_image`. | | `search_cover_images` | Search Unsplash for candidate cover photos and return their URLs, thumbnails and photographer credits. Nothing is saved; pass the chosen photo's URL to `update_trip` as `cover_image`. |
| `delete_trip` | Delete a trip. Owner only. Requires `trips:delete`. | | `delete_trip` | Delete a trip. Owner only. Requires `trips:delete`. |
+3 -2
View File
@@ -152,7 +152,8 @@ permissions still requires explicit re-consent).
the home for per-day content like outfit planning, live flight status or logistics), the home for per-day content like outfit planning, live flight status or logistics),
or a panel at the foot of a booking card in the **reservation-detail** view or a panel at the foot of a booking card in the **reservation-detail** view
(`reservation-detail` slot — receives the open `reservationId`, for things like (`reservation-detail` slot — receives the open `reservationId`, for things like
live check-in status or a seat map). Set the slot in `capabilities.widget.slot`. live check-in status or a seat map; shown on the desktop panel and on the phone's
transport and booking cards). Set the slot in `capabilities.widget.slot`.
- **trip-page** — adds a tab **inside every trip planner**, so your UI lives in the - **trip-page** — adds a tab **inside every trip planner**, so your UI lives in the
trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as
a `page`, but it receives the current `tripId` in `trek:context` (so you can scope a `page`, but it receives the current `tripId` in `trek:context` (so you can scope
@@ -248,7 +249,7 @@ parser would ignore one if you added it.
| `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}\|{rows?}, …] }`. Your file is capped at **256 MB** (a write past it fails `SQLITE_FULL`, contained to your plugin) and a single result set at **100,000 rows** — page your reads instead of materialising a cartesian product | `db:own` | | `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}\|{rows?}, …] }`. Your file is capped at **256 MB** (a write past it fails `SQLITE_FULL`, contained to your plugin) and a single result set at **100,000 rows** — page your reads instead of materialising a cartesian product | `db:own` |
| `ctx.trips` | `getById` / `getPlaces` / `getReservations` / `getDays` / `getAccommodations` / `listMine()` — enumerate every trip the acting user can access (membership-checked). `getDays` includes each day's `assignments` + `notes_items`; `getReservations` includes `endpoints` + `day_positions` | `db:read:trips` | | `ctx.trips` | `getById` / `getPlaces` / `getReservations` / `getDays` / `getAccommodations` / `listMine()` — enumerate every trip the acting user can access (membership-checked). `getDays` includes each day's `assignments` + `notes_items`; `getReservations` includes `endpoints` + `day_positions` | `db:read:trips` |
| `ctx.trips.update(tripId, fields)` | update trip fields (title/dates/currency/reminder_days/…) | `db:write:trips` | | `ctx.trips.update(tripId, fields)` | update trip fields (title/dates/currency/reminder_days/…) | `db:write:trips` |
| `ctx.trips.create(input)` | create a **new trip owned by the acting user** (importers) — `title` required, plus `description?`/`start_date?`/`end_date?`/`currency?`/`reminder_days?`/`day_count?` | `db:create:trips` (+ `trip_create`) | | `ctx.trips.create(input)` | create a **new trip owned by the acting user** (importers) — `title` required, plus `description?`/`start_date?`/`end_date?`/`currency?`/`reminder_days?`/`day_count?`; without `currency` the trip takes the acting user's display currency, then the instance default, then EUR | `db:create:trips` (+ `trip_create`) |
| `ctx.places` | `create(tripId, fields)` / `update(tripId, placeId, fields)` / `delete(tripId, placeId)` | `db:write:places` | | `ctx.places` | `create(tripId, fields)` / `update(tripId, placeId, fields)` / `delete(tripId, placeId)` | `db:write:places` |
| `ctx.days` | `create(tripId, {date?, notes?})` / `update(tripId, dayId, {notes?, title?})` / `delete(tripId, dayId)` | `db:write:days` | | `ctx.days` | `create(tripId, {date?, notes?})` / `update(tripId, dayId, {notes?, title?})` / `delete(tripId, dayId)` | `db:write:days` |
| `ctx.itinerary` | `assign(tripId, dayId, placeId, notes?)` / `unassign(tripId, assignmentId)` — place↔day | `db:write:itinerary` | | `ctx.itinerary` | `assign(tripId, dayId, placeId, notes?)` / `unassign(tripId, assignmentId)` — place↔day | `db:write:itinerary` |