feat(mcp): resolve a coordinate to a region, and report the derived ones

mark_region_visited wanted a code like US-CA that nothing in the tool
surface could produce. The visited-region list also read the raw manual
marks while the UI reads the list derived from trip places, so the two
disagreed about where somebody had been. Travel stats gained the cities and
the distance the dashboard shows.
This commit is contained in:
Maurice
2026-08-28 13:07:38 +02:00
committed by Maurice
parent 4225d883c0
commit a5ac6b15e6
2 changed files with 265 additions and 23 deletions
+56 -10
View File
@@ -44,6 +44,14 @@ function jsonContent(uri: string, data: unknown) {
* get_country_atlas_places passed codes through un-uppercased) was fixed in
* the trailing quirk commit — both sides now uppercase. The one input schema
* that grew since is create_bucket_list_item's target_date (#1898).
*
* Three later additions answer REST rather than the legacy registrar:
* locate_atlas_region mirrors GET /locate, without which nothing on the MCP
* surface could produce the region code mark_region_visited demands;
* list_visited_regions and the atlas-regions resource report the derived and
* merged shape GET /regions has always returned instead of the raw manual
* rows; and get_atlas_stats carries the dashboard passport figures from
* GET /api/auth/travel-stats next to the atlas payload.
*/
@McpController()
export class AtlasMcp {
@@ -143,33 +151,68 @@ export class AtlasMcp {
@Tool({
name: 'get_atlas_stats',
description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.',
inputSchema: {},
description:
'Get the full travel picture in one call: visited/planned countries with per-country place and trip counts, continent breakdown, trip and day totals, plus the passport figures the dashboard shows (the cities visited by name and the total distance flown). Prefer this over get_country_atlas_places, which answers for one country only.',
inputSchema: {
include_coords: z
.boolean()
.optional()
.describe('Also return the coordinate of every saved place, as the dashboard map plots them. Off by default: it is one entry per place.'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: atlasAddonOn,
access: { group: 'atlas', mode: 'read' },
})
async getAtlasStats(_args: Record<string, never>, ctx: McpContext) {
async getAtlasStats({ include_coords }: { include_coords?: boolean }, ctx: McpContext) {
const stats = await this.atlas.stats(ctx.userId);
return ok({ stats });
// stats() counts cities and knows nothing of flown distance, so the two
// figures the passport card is actually asked for (GET /api/auth/travel-stats)
// were unreachable here. Its coords array is one entry per place, which is
// rendering data rather than an answer, hence the opt-in.
const { coords, ...travel } = this.atlas.getTravelStats(ctx.userId);
return ok({ stats, travel: include_coords ? { ...travel, coords } : travel });
}
@Tool({
name: 'list_visited_regions',
description: 'List all manually visited sub-country regions for the current user.',
description:
'List visited sub-country regions grouped by country, the way the Atlas map paints them: regions derived from the places on your trips, each with a place count and a visited/planned/idea status, merged with the ones marked by hand and minus the ones dismissed.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: atlasAddonOn,
access: { group: 'atlas', mode: 'read' },
})
async listVisitedRegions(_args: Record<string, never>, ctx: McpContext) {
const regions = this.atlas.listManuallyVisitedRegions(ctx.userId);
return ok({ regions });
// The manual marks are a subset of what the user sees: GET /regions derives
// regions from trip places as well, so reporting the raw visited_regions rows
// made the tool contradict the map for anyone who never marked one by hand.
const regions = await this.atlas.visitedRegions(ctx.userId);
return ok(regions);
}
@Tool({
name: 'locate_atlas_region',
description:
'Resolve a coordinate to the country and admin1 region the Atlas map can highlight. This is where the regionCode/countryCode pair mark_region_visited expects comes from. Prefer reverse_geocode when you want a postal address instead of atlas codes.',
inputSchema: {
lat: z.number().min(-90).max(90).describe('Latitude in decimal degrees'),
lng: z.number().min(-180).max(180).describe('Longitude in decimal degrees'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: atlasAddonOn,
access: { group: 'atlas', mode: 'read' },
})
async locateAtlasRegion({ lat, lng }: { lat: number; lng: number }, _ctx: McpContext) {
// Null fields are an answer, not a failure: the point may sit outside every
// bundled polygon, and plenty of countries have no admin1 coverage at all
// (#1115). Same as the REST route.
const located = await this.atlas.locate(lat, lng);
return ok(located);
}
@Tool({
name: 'mark_region_visited',
description: 'Mark a sub-country region as visited.',
description: 'Mark a sub-country region as visited. When you only know a place or a coordinate, get the codes from locate_atlas_region first.',
inputSchema: {
regionCode: z.string().describe('ISO region code e.g. US-CA'),
regionName: z.string(),
@@ -317,13 +360,16 @@ export class AtlasMcp {
@Resource({
name: 'atlas-regions',
uri: 'trek://atlas/regions',
description: 'List of manually visited regions for the current user',
description: 'Visited regions grouped by country, as the Atlas map paints them',
mimeType: 'application/json',
when: atlasAddonOn,
access: { group: 'atlas', mode: 'read' },
})
async atlasRegionsResource(uri: URL, ctx: McpContext) {
const regions = this.atlas.listManuallyVisitedRegions(ctx.userId);
// Same source as list_visited_regions. Resources here carry the payload
// itself, so the { regions } envelope GET /regions sends is unwrapped, the
// way bucket-list drops REST's { items }.
const { regions } = await this.atlas.visitedRegions(ctx.userId);
return jsonContent(uri.href, regions);
}
}
@@ -1,7 +1,7 @@
/**
* Unit tests for MCP atlas expanded tools (atlas addon-gated):
* get_atlas_stats, list_visited_regions, mark_region_visited, unmark_region_visited,
* get_country_atlas_places, update_bucket_list_item.
* get_atlas_stats, list_visited_regions, locate_atlas_region, mark_region_visited,
* unmark_region_visited, get_country_atlas_places, update_bucket_list_item.
* Also covers resources trek://atlas/stats and trek://atlas/regions.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -39,7 +39,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
import { createUser, createBucketListItem, createVisitedCountry, createTrip, createReservation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { setAddonEnabled } from '../../helpers/test-db';
import { ADDON_IDS } from '../../../src/addons';
@@ -73,6 +73,33 @@ async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promis
try { await fn(h); } finally { await h.cleanup(); }
}
// A place carrying an address and coordinates, which createPlace does not write.
function insertPlace(tripId: number, name: string, address: string | null, lat: number, lng: number): number {
const r = testDb
.prepare('INSERT INTO places (trip_id, name, address, lat, lng) VALUES (?, ?, ?, ?, ?)')
.run(tripId, name, address, lat, lng);
return r.lastInsertRowid as number;
}
// The geocoder's answer for a place, pre-seeded so visitedRegions() reads the cache
// and never reaches for Nominatim (same trick as ATLAS-UNIT-020).
function cacheRegion(placeId: number, countryCode: string, regionCode: string, regionName: string): void {
testDb
.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(placeId, countryCode, regionCode, regionName);
}
function insertEndpoint(reservationId: number, role: 'from' | 'to', sequence: number, lat: number, lng: number): void {
testDb
.prepare('INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, lat, lng) VALUES (?, ?, ?, ?, ?, ?)')
.run(reservationId, role, sequence, `Endpoint ${sequence}`, lat, lng);
}
// Trips have to sit in the past for their countries and regions to count as
// visited rather than planned (#1048).
const PAST_START = '2023-05-01';
const PAST_END = '2023-05-10';
// ---------------------------------------------------------------------------
// get_atlas_stats
// ---------------------------------------------------------------------------
@@ -85,6 +112,65 @@ describe('Tool: get_atlas_stats', () => {
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
expect(data.stats).toBeDefined();
expect(data.travel).toEqual({
countries: [],
cities: [],
totalTrips: 0,
totalDays: 0,
totalPlaces: 0,
totalDistanceKm: 0,
});
});
});
it('carries the passport figures REST answers with: the cities by name and the distance flown', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922);
const flight = createReservation(testDb, trip.id, { type: 'flight', title: 'FCO-JFK' });
insertEndpoint(flight.id, 'from', 0, 41.8003, 12.2389);
insertEndpoint(flight.id, 'to', 1, 40.6413, -73.7781);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} });
const data = parseToolResult(result) as any;
// The count was always there; the names behind it were not.
expect(data.stats.stats.totalCities).toBe(1);
expect(data.travel.cities).toEqual(['Rome']);
expect(data.travel.countries).toContain('IT');
expect(data.travel.countries).toContain('US');
// Rome to New York, so a four-figure number rather than a rounding artefact.
expect(data.travel.totalDistanceKm).toBeGreaterThan(6000);
const places = testDb.prepare('SELECT COUNT(*) AS c FROM places WHERE trip_id = ?').get(trip.id) as { c: number };
expect(data.travel.totalPlaces).toBe(places.c);
// Rendering data stays out unless asked for.
expect(data.travel.coords).toBeUndefined();
});
});
it('include_coords adds the per-place coordinates the dashboard map plots', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
insertPlace(trip.id, 'Colosseum', 'Colosseum, Rome, Italy', 41.8902, 12.4922);
insertPlace(trip.id, 'Pantheon', 'Pantheon, Rome, Italy', 41.8986, 12.4769);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: { include_coords: true } });
const data = parseToolResult(result) as any;
const withCoords = testDb
.prepare('SELECT COUNT(*) AS c FROM places WHERE trip_id = ? AND lat IS NOT NULL AND lng IS NOT NULL')
.get(trip.id) as { c: number };
expect(data.travel.coords).toHaveLength(withCoords.c);
expect(data.travel.coords[0]).toEqual({ lat: 41.8902, lng: 12.4922 });
});
});
it('refuses a non-boolean include_coords', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: { include_coords: 'yes' } });
expect(result.isError).toBe(true);
expect((result.content[0] as any).text).toContain('include_coords');
});
});
});
@@ -94,16 +180,16 @@ describe('Tool: get_atlas_stats', () => {
// ---------------------------------------------------------------------------
describe('Tool: list_visited_regions', () => {
it('returns empty array initially', async () => {
it('returns an empty grouping initially', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions).toEqual([]);
expect(data.regions).toEqual({});
});
});
it('returns regions after they have been inserted', async () => {
it('groups a manual mark under its country, in the shape the map reads', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
@@ -111,8 +197,113 @@ describe('Tool: list_visited_regions', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions).toHaveLength(1);
expect(data.regions[0].region_code).toBe('FR-75');
expect(data.regions.FR).toEqual([
{ code: 'FR-75', name: 'Paris', placeCount: 0, status: 'visited', manuallyMarked: true },
]);
});
});
it('reports regions derived from trip places, which the manual table never held', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
cacheRegion(insertPlace(trip.id, 'Pantheon', null, 41.8986, 12.4769), 'IT', 'IT-62', 'Lazio');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions.IT).toEqual([{ code: 'IT-62', name: 'Lazio', placeCount: 2, status: 'visited' }]);
// Nothing was ever marked by hand, so the old manual-only read had no row
// to answer with at all.
const marked = testDb.prepare('SELECT COUNT(*) AS c FROM visited_regions WHERE user_id = ?').get(user.id) as { c: number };
expect(marked.c).toBe(0);
});
});
it('drops a region the user dismissed, matching the map', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
await withHarness(user.id, async (h) => {
const unmark = await h.client.callTool({ name: 'unmark_region_visited', arguments: { regionCode: 'IT-62' } });
expect(parseToolResult(unmark)).toEqual({ success: true });
const tombstone = testDb
.prepare('SELECT region_code FROM hidden_regions WHERE user_id = ? AND region_code = ?')
.get(user.id, 'IT-62');
expect(tombstone).toBeTruthy();
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
expect(data.regions.IT).toBeUndefined();
});
});
});
// ---------------------------------------------------------------------------
// locate_atlas_region
// ---------------------------------------------------------------------------
describe('Tool: locate_atlas_region', () => {
it('resolves a coordinate to the country and the region the map can highlight', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'locate_atlas_region',
arguments: { lat: 41.9028, lng: 12.4964 },
});
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
// Rome. Italy has admin1 coverage in the bundle, so the region resolves too,
// and the code comes back in the form mark_region_visited takes.
expect(data.country_code).toBe('IT');
expect(typeof data.region_code).toBe('string');
expect(typeof data.region_name).toBe('string');
});
});
it('feeds mark_region_visited without the caller knowing any codes', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const located = parseToolResult(
await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 41.9028, lng: 12.4964 } }),
) as any;
expect(typeof located.region_code).toBe('string');
await h.client.callTool({
name: 'mark_region_visited',
arguments: {
regionCode: located.region_code,
regionName: located.region_name,
countryCode: located.country_code,
},
});
const row = testDb
.prepare('SELECT region_code, country_code FROM visited_regions WHERE user_id = ?')
.get(user.id) as { region_code: string; country_code: string };
expect(row).toEqual({ region_code: located.region_code, country_code: 'IT' });
});
});
it('answers with nulls out at sea rather than failing', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 0, lng: -140 } });
expect(result.isError).toBeFalsy();
expect(parseToolResult(result)).toEqual({ country_code: null, region_code: null, region_name: null });
});
});
it('refuses an out-of-range coordinate, like the REST route does', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const badLat = await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 91, lng: 12.5 } });
expect(badLat.isError).toBe(true);
expect((badLat.content[0] as any).text).toContain('lat');
const badLng = await h.client.callTool({ name: 'locate_atlas_region', arguments: { lat: 41.9, lng: 181 } });
expect(badLng.isError).toBe(true);
expect((badLng.content[0] as any).text).toContain('lng');
});
});
});
@@ -348,25 +539,30 @@ describe('Resource: trek://atlas/stats', () => {
// ---------------------------------------------------------------------------
describe('Resource: trek://atlas/regions', () => {
it('returns regions array', async () => {
it('returns the country grouping', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
const data = parseResourceResult(result) as any;
expect(Array.isArray(data)).toBe(true);
expect(data).toEqual({});
});
});
it('returns inserted regions', async () => {
it('returns marked and derived regions together, as the tool does', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'ES-CT', 'Catalonia', 'ES');
const trip = createTrip(testDb, user.id, { title: 'Rome', start_date: PAST_START, end_date: PAST_END });
cacheRegion(insertPlace(trip.id, 'Colosseum', null, 41.8902, 12.4922), 'IT', 'IT-62', 'Lazio');
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
const data = parseResourceResult(result) as any;
expect(data).toHaveLength(1);
expect(data[0].region_code).toBe('ES-CT');
expect(data.ES).toEqual([
{ code: 'ES-CT', name: 'Catalonia', placeCount: 0, status: 'visited', manuallyMarked: true },
]);
expect(data.IT).toEqual([{ code: 'IT-62', name: 'Lazio', placeCount: 1, status: 'visited' }]);
});
});
});