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.
This commit is contained in:
Maurice
2026-10-22 00:03:19 +00:00
parent 3c0f324266
commit 6d6efddc4e
63 changed files with 2418 additions and 51 deletions
@@ -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');
});
});
@@ -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));
@@ -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<Record<string, number> | 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<number[][]> => {
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();
});
});
@@ -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');
});
});
@@ -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 ────────────────────────────────────────────────────────────────────
@@ -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 }),
);
});
});
@@ -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);
});
});
@@ -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<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
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.' });
});
});