/** * The MCP half of the plugin POI categories (#1781): list_plugin_poi_categories and * search_plugin_pois (src/nest/plugins/contributions/plugin-pois.mcp.ts), the * counterparts of the GET /api/plugins feed and GET /api/plugin-pois. Both go through * PluginPoisService, so the gate, the window and the normalization are the REST ones. */ import { db as testDb } from '../../../src/db/database'; import { Plugins } from '../../../src/db/entities/Plugins.entity'; import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; import { createUser } from '../../helpers/factories'; import { makePlugin } from '../../helpers/factories/plugins'; import { deleteRows } from '../../helpers/factories/rows'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { resetTestDb } from '../../helpers/test-db'; import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) })); vi.mock('../../../src/db/database', async () => { const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock'); return buildDbMock(createSnapshotTestDb()); }); vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf'); const categoryPoisMock = vi.spyOn(PluginHooks.prototype, 'categoryPois'); const trailheads = { id: 'trailheads', label: 'Trailheads', labels: { de: 'Wanderparkplätze' }, icon: 'Signpost', color: '#2f855a', }; const bbox = { south: 47, west: 11, north: 47.5, east: 11.5 }; let orm: TestOrm; beforeAll(async () => { orm = await createTestOrm(testDb); }); beforeEach(async () => { resetTestDb(testDb); await deleteRows(orm, Plugins); await makePlugin(orm, 'trail-finder', { name: 'Trail Finder', status: 'active', capabilities: JSON.stringify({ poiCategories: [trailheads] }), granted_permissions: JSON.stringify(['hook:poi-category-provider']), }); pluginsEnabled.mockReturnValue(true); providersOfMock.mockReset().mockReturnValue(['trail-finder']); categoryPoisMock.mockReset().mockResolvedValue([]); }); afterAll(async () => { await orm.close(); testDb.close(); }); async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { const h = await createMcpHarness({ userId, withResources: false, scopes: null }); try { await fn(h); } finally { await h.cleanup(); } } const search = (h: McpHarness, args: Record) => h.client.callTool({ name: 'search_plugin_pois', arguments: args }); describe('Tool: list_plugin_poi_categories', () => { it('names the categories a provider answers, labelled in the requested language', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const res = await h.client.callTool({ name: 'list_plugin_poi_categories', arguments: { lang: 'de' } }); expect(parseToolResult(res)).toEqual({ categories: [ { key: 'plugin:trail-finder/trailheads', pluginId: 'trail-finder', pluginName: 'Trail Finder', id: 'trailheads', label: 'Wanderparkplätze', icon: 'Signpost', color: '#2f855a', }, ], }); expect(providersOfMock).toHaveBeenCalledWith('poiCategoryProvider'); }); }); it('is empty while the plugin runtime is switched off', async () => { const { user } = createUser(testDb); pluginsEnabled.mockReturnValue(false); await withHarness(user.id, async (h) => { const res = await h.client.callTool({ name: 'list_plugin_poi_categories', arguments: {} }); expect(parseToolResult(res)).toEqual({ categories: [] }); }); }); }); describe('Tool: search_plugin_pois', () => { it('returns the plugin answer in the search_pois row shape, asked as the requesting user', async () => { const { user } = createUser(testDb); categoryPoisMock.mockResolvedValue([ { id: 'th-1', name: 'Trailhead', lat: 47.2, lng: 11.2, rating: 4.5, details: [{ label: 'Length', value: '12 km' }], }, { id: 'far', name: 'Elsewhere', lat: 50, lng: 11.2 }, ]); await withHarness(user.id, async (h) => { const payload = parseToolResult( await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox, lang: 'de' }), ) as { pois: Array>; source: string; truncated: boolean; clamped: boolean; }; expect(payload.source).toBe('plugin:trail-finder'); expect(payload.clamped).toBe(false); expect(payload.pois).toHaveLength(1); expect(payload.pois[0]).toMatchObject({ osm_id: 'plugin:trail-finder:th-1', category: 'plugin:trail-finder/trailheads', pluginId: 'trail-finder', rating: 4.5, details: [{ label: 'Length', value: '12 km' }], icon: 'Signpost', color: '#2f855a', }); expect(categoryPoisMock).toHaveBeenCalledWith( 'trail-finder', { category: 'trailheads', bounds: bbox, lang: 'de', limit: 60 }, user.id, ); }); }); it('refuses an undeclared category and an inverted box without asking the plugin', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const unknown = await search(h, { pluginId: 'trail-finder', category: 'huts', bbox }); expect(unknown.isError).toBe(true); expect(JSON.stringify(unknown.content)).toContain('list_plugin_poi_categories'); const inverted = await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox: { ...bbox, south: 48 }, }); expect(inverted.isError).toBe(true); expect(categoryPoisMock).not.toHaveBeenCalled(); }); }); it('takes the box search_pois takes and refuses an edge off the globe without asking the plugin', async () => { const { user } = createUser(testDb); await withHarness(user.id, async (h) => { const { tools } = await h.client.listTools(); const bboxOf = (name: string) => tools.find((t) => t.name === name)?.inputSchema.properties?.bbox; expect(bboxOf('search_plugin_pois')).toBeDefined(); expect(bboxOf('search_plugin_pois')).toEqual(bboxOf('search_pois')); const offGlobe = await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox: { ...bbox, north: 118 }, }); expect(offGlobe.isError).toBe(true); expect(categoryPoisMock).not.toHaveBeenCalled(); }); }); it('turns a failing plugin into an error result', async () => { const { user } = createUser(testDb); categoryPoisMock.mockRejectedValue(new Error('timed out')); await withHarness(user.id, async (h) => { const res = await search(h, { pluginId: 'trail-finder', category: 'trailheads', bbox }); expect(res.isError).toBe(true); expect(JSON.stringify(res.content)).toContain('Plugin POI search failed.'); }); }); });