Merge pull request #429 from mauriceboe/fix/mcp-search-place-google-maps
fix(mcp): route search_place through mapsService to support Google Maps
This commit is contained in:
@@ -22,6 +22,7 @@ import { createNote as createCollabNote, updateNote as updateCollabNote, deleteN
|
|||||||
import {
|
import {
|
||||||
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
|
markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem,
|
||||||
} from '../services/atlasService';
|
} from '../services/atlasService';
|
||||||
|
import { searchPlaces } from '../services/mapsService';
|
||||||
|
|
||||||
const MAX_MCP_TRIP_DAYS = 90;
|
const MAX_MCP_TRIP_DAYS = 90;
|
||||||
|
|
||||||
@@ -235,25 +236,12 @@ export function registerTools(server: McpServer, userId: number): void {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ query }) => {
|
async ({ query }) => {
|
||||||
// Use Nominatim (no API key needed, always available)
|
try {
|
||||||
const params = new URLSearchParams({
|
const result = await searchPlaces(userId, query);
|
||||||
q: query, format: 'json', addressdetails: '1', limit: '5', 'accept-language': 'en',
|
return ok(result);
|
||||||
});
|
} catch {
|
||||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true };
|
||||||
headers: { 'User-Agent': 'TREK Travel Planner' },
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return { content: [{ type: 'text' as const, text: 'Search failed — Nominatim API error.' }], isError: true };
|
|
||||||
}
|
}
|
||||||
const data = await response.json() as { osm_type: string; osm_id: number; name: string; display_name: string; lat: string; lon: string }[];
|
|
||||||
const places = data.map(item => ({
|
|
||||||
osm_id: `${item.osm_type}:${item.osm_id}`,
|
|
||||||
name: item.name || item.display_name?.split(',')[0] || '',
|
|
||||||
address: item.display_name || '',
|
|
||||||
lat: parseFloat(item.lat) || null,
|
|
||||||
lng: parseFloat(item.lon) || null,
|
|
||||||
}));
|
|
||||||
return ok({ places });
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ vi.mock('../../../src/config', () => ({
|
|||||||
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
|
||||||
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
|
||||||
|
|
||||||
|
const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() }));
|
||||||
|
vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlacesMock }));
|
||||||
|
|
||||||
import { createTables } from '../../../src/db/schema';
|
import { createTables } from '../../../src/db/schema';
|
||||||
import { runMigrations } from '../../../src/db/migrations';
|
import { runMigrations } from '../../../src/db/migrations';
|
||||||
import { resetTestDb } from '../../helpers/test-db';
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
@@ -51,6 +54,7 @@ beforeAll(() => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetTestDb(testDb);
|
resetTestDb(testDb);
|
||||||
broadcastMock.mockClear();
|
broadcastMock.mockClear();
|
||||||
|
searchPlacesMock.mockClear();
|
||||||
delete process.env.DEMO_MODE;
|
delete process.env.DEMO_MODE;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,44 +271,53 @@ describe('Tool: list_categories', () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('Tool: search_place', () => {
|
describe('Tool: search_place', () => {
|
||||||
it('returns formatted results from Nominatim', async () => {
|
it('returns OSM results when no Google key is configured', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const mockFetch = vi.fn().mockResolvedValue({
|
searchPlacesMock.mockResolvedValue({
|
||||||
ok: true,
|
source: 'openstreetmap',
|
||||||
json: async () => [
|
places: [
|
||||||
{
|
{ osm_id: 'node:12345', name: 'Eiffel Tower', address: 'Eiffel Tower, Paris, France', lat: 48.8584, lng: 2.2945 },
|
||||||
osm_type: 'node',
|
|
||||||
osm_id: 12345,
|
|
||||||
name: 'Eiffel Tower',
|
|
||||||
display_name: 'Eiffel Tower, Paris, France',
|
|
||||||
lat: '48.8584',
|
|
||||||
lon: '2.2945',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
|
||||||
|
|
||||||
await withHarness(user.id, async (h) => {
|
await withHarness(user.id, async (h) => {
|
||||||
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'Eiffel Tower' } });
|
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'Eiffel Tower' } });
|
||||||
const data = parseToolResult(result) as any;
|
const data = parseToolResult(result) as any;
|
||||||
|
expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Eiffel Tower');
|
||||||
expect(data.places).toHaveLength(1);
|
expect(data.places).toHaveLength(1);
|
||||||
expect(data.places[0].name).toBe('Eiffel Tower');
|
|
||||||
expect(data.places[0].osm_id).toBe('node:12345');
|
expect(data.places[0].osm_id).toBe('node:12345');
|
||||||
|
expect(data.places[0].name).toBe('Eiffel Tower');
|
||||||
expect(data.places[0].lat).toBeCloseTo(48.8584);
|
expect(data.places[0].lat).toBeCloseTo(48.8584);
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error when Nominatim API fails', async () => {
|
it('returns google_place_id when Google Maps is configured', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
searchPlacesMock.mockResolvedValue({
|
||||||
|
source: 'google',
|
||||||
|
places: [
|
||||||
|
{ google_place_id: 'ChIJD3uTd9hx5kcR1IQvGfr8dbk', name: 'Eiffel Tower', address: 'Champ de Mars, Paris', lat: 48.8584, lng: 2.2945, rating: 4.7, website: 'https://toureiffel.paris', phone: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'Eiffel Tower' } });
|
||||||
|
const data = parseToolResult(result) as any;
|
||||||
|
expect(searchPlacesMock).toHaveBeenCalledWith(user.id, 'Eiffel Tower');
|
||||||
|
expect(data.places).toHaveLength(1);
|
||||||
|
expect(data.places[0].google_place_id).toBe('ChIJD3uTd9hx5kcR1IQvGfr8dbk');
|
||||||
|
expect(data.places[0].name).toBe('Eiffel Tower');
|
||||||
|
expect(data.places[0].rating).toBe(4.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when place search fails', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
searchPlacesMock.mockRejectedValue(new Error('Search failed'));
|
||||||
|
|
||||||
await withHarness(user.id, async (h) => {
|
await withHarness(user.id, async (h) => {
|
||||||
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'something' } });
|
const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'something' } });
|
||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user