diff --git a/CHANGELOG.md b/CHANGELOG.md index 928cc40..a4f608b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## Unreleased +### Breaking Changes + +- **`place_details_tool` now calls the Mapbox Places API instead of the older Details API.** The tool previously called `search/details/v1/retrieve` (`docs.mapbox.com/api/search/details/`); it now calls `places/v1/details/retrieve` (`docs.mapbox.com/api/search/places/`), a separate, newer product built around a larger POI dataset. This is not a compatible upgrade: + - **Input**: `attribute_sets`, `language`, and `worldview` are removed from the input schema. The Places API's Details endpoint takes only a `mapbox_id` and has no equivalent parameters. + - **Output**: the response is no longer a GeoJSON `Feature`. It's a flat object (`name`, `full_address`, `phone`, `website`, `categories`, `opening_hours` as a plain OSM-format string, `coordinates: { latitude, longitude }`, `score: { popularity, reality, closed }`, `address`, `attributes`, `photos`, `building`) — see `PlaceDetailsTool.output.schema.ts`. Callers reading `properties.*` or `geometry.coordinates` from the old shape need to update to the new field names. + - **Lost fields**: the old API's `rating`/`review_count`/`price` (user rating and review count) have no equivalent in the new API. `score.popularity`/`score.reality` are data-quality/confidence signals, not user ratings, and are preserved in the formatted text output as "Popularity: N%". + - The Places API is **Public Preview**: its default quota is 1,000 records/month per account and 100 records/sec, and its response contract may change without notice. The output schema is deliberately permissive (`.passthrough()` throughout, most fields optional) to avoid the class of output-validation failure fixed in 0.14.0 if the API adds or omits fields. + - We are not supporting both APIs going forward, since the server isn't at 1.0 yet. + ### Changed - **Tool calls are now bound to the MCP server that received them.** `BaseTool` tracked its target server in a single mutable instance field set by `installTo()`, and the callback it registered read that field at call time rather than capturing the server it was registered on. Because the pre-configured instances exported from `@mapbox/mcp-server/tools` are module-level singletons, an application that installed one instance into more than one `McpServer` would have the later `installTo()` silently redirect the earlier server's callbacks: logging, sampling (`ground_location_tool`), and elicitations (`search_and_geocode_tool`, `directions_tool`) would all be sent to whichever server was installed last. Each invocation now resolves the server that registered its callback, via `AsyncLocalStorage`, so concurrent calls arriving through different servers stay on their own. Single-server applications — including the server shipped by this package — behave exactly as before. Calling `run()` directly, outside a registered callback, still falls back to the most recently installed server. diff --git a/src/tools/place-details-tool/PlaceDetailsTool.input.schema.ts b/src/tools/place-details-tool/PlaceDetailsTool.input.schema.ts index 1450e21..97d8061 100644 --- a/src/tools/place-details-tool/PlaceDetailsTool.input.schema.ts +++ b/src/tools/place-details-tool/PlaceDetailsTool.input.schema.ts @@ -11,24 +11,6 @@ export const PlaceDetailsInputSchema = z.object({ .string() .describe( 'The Mapbox ID of the place to retrieve details for. Obtained from search results returned by search_and_geocode_tool, category_search_tool, or reverse_geocode_tool (the mapbox_id field in properties).' - ), - attribute_sets: z - .array(z.enum(['basic', 'photos', 'visit', 'venue'])) - .optional() - .describe( - 'Which attribute sets to include in the response. Options: "basic" (name/address/coordinates — always requested from the API regardless of whether you list it here, since it\'s required for a valid response), "photos" (place photo URLs), "visit" (opening hours, rating, price level, popularity), "venue" (phone number, website URL, social media links). When not specified, only basic attributes are returned.' - ), - language: z - .string() - .optional() - .describe( - 'BCP 47 language tag for localized results (e.g. "en", "fr", "de", "ja"). Affects place names and address formatting.' - ), - worldview: z - .enum(['ar', 'cn', 'in', 'jp', 'ma', 'ru', 'tr', 'us']) - .optional() - .describe( - 'Worldview for geopolitically sensitive content such as disputed borders. Options: "ar" (Argentina), "cn" (China), "in" (India), "jp" (Japan), "ma" (Morocco), "ru" (Russia), "tr" (Turkey), "us" (United States, default).' ) }); diff --git a/src/tools/place-details-tool/PlaceDetailsTool.output.schema.ts b/src/tools/place-details-tool/PlaceDetailsTool.output.schema.ts index c811771..dc41fa2 100644 --- a/src/tools/place-details-tool/PlaceDetailsTool.output.schema.ts +++ b/src/tools/place-details-tool/PlaceDetailsTool.output.schema.ts @@ -6,49 +6,77 @@ import { z } from 'zod'; /** * Output schema for PlaceDetailsTool * - * Models the GeoJSON Feature returned by the Mapbox Details API. - * Uses passthrough() to allow additional fields from optional attribute_sets - * (photos, visit, venue) to pass through without validation errors. + * Models the flat Place record returned by the Mapbox Places API's Details + * endpoint (`places/v1/details/retrieve`), currently in Public Preview. + * Nearly every field beyond `mapbox_id`/`name` is optional and every nested + * object uses .passthrough() — live responses observed during development + * varied in which fields were present (e.g. `building` and `opening_hours` + * only appear on some places), and a Public Preview API's contract can add + * fields without notice, so this schema is deliberately permissive rather + * than risk an output-validation failure like the one fixed for the + * previous Details API (see CHANGELOG). */ export const PlaceDetailsOutputSchema = z .object({ - type: z.literal('Feature'), - geometry: z + mapbox_id: z.string(), + name: z.string(), + full_address: z.string().optional(), + brand: z.string().nullable().optional(), + primary_category: z.string().optional(), + categories: z.array(z.string()).optional(), + // OSM opening_hours syntax, e.g. "Mo 09:00-23:45; Tu 09:00-23:45; ...". + opening_hours: z.string().optional(), + permanently_closed: z.boolean().nullable().optional(), + phone: z.string().optional(), + website: z.string().optional(), + status: z.string().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), + score: z .object({ - type: z.literal('Point'), - coordinates: z.tuple([z.number(), z.number()]) + closed: z.number().nullable().optional(), + reality: z.number().nullable().optional(), + popularity: z.number().nullable().optional() }) - .passthrough(), - properties: z + .passthrough() + .optional(), + coordinates: z .object({ - name: z.string(), - mapbox_id: z.string(), - feature_type: z.string(), - address: z.string().optional(), - full_address: z.string().optional(), - place_formatted: z.string().optional(), - context: z.object({}).passthrough().optional(), - coordinates: z + latitude: z.number(), + longitude: z.number(), + source: z.string().optional(), + routable_points: z + .array( + z + .object({ + name: z.string().optional(), + latitude: z.number(), + longitude: z.number() + }) + .passthrough() + ) + .optional() + }) + .passthrough() + .optional(), + address: z.object({}).passthrough().optional(), + attributes: z + .record(z.string(), z.union([z.string(), z.boolean(), z.number()])) + .optional(), + building: z.object({}).passthrough().optional(), + photos: z + .array( + z .object({ - longitude: z.number(), - latitude: z.number() + url: z.string(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + source: z.string().optional() }) .passthrough() - .optional(), - bbox: z - .tuple([z.number(), z.number(), z.number(), z.number()]) - .optional(), - language: z.string().optional(), - maki: z.string().optional(), - poi_category: z.array(z.string()).optional(), - poi_category_ids: z.array(z.string()).optional(), - brand: z.array(z.string()).optional(), - brand_id: z.array(z.string()).optional(), - external_ids: z.record(z.string(), z.string()).optional(), - // metadata contains attribute_set fields (photos, visit, venue) - metadata: z.object({}).passthrough().optional() - }) - .passthrough() + ) + .optional(), + telemetry: z.object({}).passthrough().optional() }) .passthrough(); diff --git a/src/tools/place-details-tool/PlaceDetailsTool.ts b/src/tools/place-details-tool/PlaceDetailsTool.ts index 0411c4a..a676971 100644 --- a/src/tools/place-details-tool/PlaceDetailsTool.ts +++ b/src/tools/place-details-tool/PlaceDetailsTool.ts @@ -12,7 +12,13 @@ import { type PlaceDetailsOutput } from './PlaceDetailsTool.output.schema.js'; -// API Documentation: https://docs.mapbox.com/api/search/details/ +// API Documentation: https://docs.mapbox.com/api/search/places/ +// +// This calls the Places API's Details/Retrieve endpoint, not the older, +// separate Details API (docs.mapbox.com/api/search/details/) this tool used +// previously. The Places API is Public Preview: its default quota is 1,000 +// records/month per account and 100 records/sec, and its response contract +// may change without notice. export class PlaceDetailsTool extends MapboxApiBasedTool< typeof PlaceDetailsInputSchema, @@ -20,7 +26,7 @@ export class PlaceDetailsTool extends MapboxApiBasedTool< > { name = 'place_details_tool'; description = - 'Retrieve detailed information about a specific place using its Mapbox ID. Use after search_and_geocode_tool, category_search_tool, or reverse_geocode_tool to get additional details such as photos, opening hours, ratings, phone numbers, and website URLs. Requires the mapbox_id field from a previous search result.'; + 'Retrieve detailed information about a specific place using its Mapbox ID. Use after search_and_geocode_tool, category_search_tool, or reverse_geocode_tool to get additional details such as photos, opening hours, phone numbers, and website URLs. Requires the mapbox_id field from a previous search result.'; annotations = { title: 'Place Details Tool', readOnlyHint: true, @@ -37,155 +43,71 @@ export class PlaceDetailsTool extends MapboxApiBasedTool< }); } - private formatOpenHours(openHours: Record): string { - const DAY_NAMES = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday' - ]; - - // Use weekday_text if the API provides it — already formatted per day - if (Array.isArray(openHours['weekday_text'])) { - const lines = (openHours['weekday_text'] as string[]) - .map((line) => ` ${line}`) - .join('\n'); - return `Hours:\n${lines}`; - } - - // Fall back to parsing periods array - if (!Array.isArray(openHours['periods'])) return ''; - - type Period = { - open: { day: number; time: string }; - close?: { day: number; time: string }; - }; - - const formatTime = (hhmm: string): string => { - const h = parseInt(hhmm.slice(0, 2), 10); - const m = hhmm.slice(2); - const period = h < 12 ? 'AM' : 'PM'; - const hour = h % 12 || 12; - return m === '00' ? `${hour} ${period}` : `${hour}:${m} ${period}`; - }; - - // Group periods by open day - const byDay = new Map(); - for (const period of openHours['periods'] as Period[]) { - const day = period.open.day; - const open = formatTime(period.open.time); - const close = period.close ? formatTime(period.close.time) : 'midnight'; - const range = `${open} – ${close}`; - const existing = byDay.get(day); - if (existing) { - existing.push(range); - } else { - byDay.set(day, [range]); - } - } - - const dayLines = DAY_NAMES.map((name, i) => { - const ranges = byDay.get(i); - return ` ${name}: ${ranges ? ranges.join(', ') : 'Closed'}`; - }); + /** `opening_hours` is an OSM opening_hours string, e.g. "Mo 09:00-23:45; Tu 09:00-23:45; ...". */ + private formatOpeningHours(openingHours: string): string { + const parts = openingHours + .split(';') + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length === 0) return ''; - return `Hours:\n${dayLines.join('\n')}`; + const lines = parts.map((part) => ` ${part}`).join('\n'); + return `Hours:\n${lines}`; } private formatDetailsToText(data: PlaceDetailsOutput): string { - const props = data.properties; const lines: string[] = []; - // Name - lines.push(`Name: ${props.name}`); + lines.push(`Name: ${data.name}`); - // Address - if (props.full_address) { - lines.push(`Address: ${props.full_address}`); - } else if (props.place_formatted) { - lines.push(`Address: ${props.place_formatted}`); - } else if (props.address) { - lines.push(`Address: ${props.address}`); + if (data.full_address) { + lines.push(`Address: ${data.full_address}`); } - // Coordinates from geometry - if (data.geometry?.coordinates) { - const [lng, lat] = data.geometry.coordinates; - lines.push(`Coordinates: ${lat}, ${lng}`); + if (data.coordinates) { + lines.push( + `Coordinates: ${data.coordinates.latitude}, ${data.coordinates.longitude}` + ); } - // Feature type and categories - if (props.feature_type) { - lines.push(`Type: ${props.feature_type}`); + if (data.primary_category) { + lines.push(`Type: ${data.primary_category}`); } - if (props.poi_category && props.poi_category.length > 0) { - lines.push(`Category: ${props.poi_category.join(', ')}`); + if (data.categories && data.categories.length > 0) { + lines.push(`Category: ${data.categories.join(', ')}`); } - // Brand - if (props.brand && props.brand.length > 0) { - lines.push(`Brand: ${props.brand.join(', ')}`); + if (data.brand) { + lines.push(`Brand: ${data.brand}`); } - // Venue attributes (phone, website, social media) - const metadata = props.metadata as Record | undefined; - if (metadata) { - if (metadata['phone']) { - lines.push(`Phone: ${metadata['phone']}`); - } - if (metadata['website']) { - lines.push(`Website: ${metadata['website']}`); - } - if ( - metadata['social_media'] && - typeof metadata['social_media'] === 'object' - ) { - const social = metadata['social_media'] as Record; - const socialLinks = Object.entries(social) - .map(([k, v]) => `${k}: ${v}`) - .join(', '); - if (socialLinks) lines.push(`Social: ${socialLinks}`); - } + if (data.phone) { + lines.push(`Phone: ${data.phone}`); + } + if (data.website) { + lines.push(`Website: ${data.website}`); + } - // Visit attributes (hours, rating, price) - if (metadata['price']) { - lines.push(`Price: ${metadata['price']}`); - } - if (metadata['rating'] !== undefined) { - lines.push(`Rating: ${metadata['rating']}`); - } - if (metadata['review_count'] !== undefined) { - lines.push(`Reviews: ${metadata['review_count']}`); - } - if (metadata['popularity'] !== undefined) { - lines.push( - `Popularity: ${Math.round((metadata['popularity'] as number) * 100)}%` - ); - } - if ( - metadata['open_hours'] && - typeof metadata['open_hours'] === 'object' - ) { - const formatted = this.formatOpenHours( - metadata['open_hours'] as Record - ); - if (formatted) lines.push(formatted); - } + if ( + data.score?.popularity !== undefined && + data.score?.popularity !== null + ) { + lines.push(`Popularity: ${Math.round(data.score.popularity * 100)}%`); + } + + if (data.permanently_closed) { + lines.push('Status: Permanently closed'); + } - // Photos - if (Array.isArray(metadata['primary_photo'])) { - const photos = metadata['primary_photo'] as Array< - Record - >; - const photoUrls = photos - .map((p) => p['url'] || p['thumb_url']) - .filter(Boolean); - if (photoUrls.length > 0) { - lines.push(`Photos: ${photoUrls.join(', ')}`); - } + if (data.opening_hours) { + const formatted = this.formatOpeningHours(data.opening_hours); + if (formatted) lines.push(formatted); + } + + if (data.photos && data.photos.length > 0) { + const urls = data.photos.map((photo) => photo.url).filter(Boolean); + if (urls.length > 0) { + lines.push(`Photos: ${urls.join(', ')}`); } } @@ -199,30 +121,11 @@ export class PlaceDetailsTool extends MapboxApiBasedTool< _context: ToolExecutionContext ): Promise { const url = new URL( - `${MapboxApiBasedTool.mapboxApiEndpoint}search/details/v1/retrieve/${encodeURIComponent(input.mapbox_id)}` + `${MapboxApiBasedTool.mapboxApiEndpoint}places/v1/details/retrieve/${encodeURIComponent(input.mapbox_id)}` ); url.searchParams.append('access_token', accessToken); - // "basic" (name, feature_type, address, coordinates) is what the - // Details API calls its default attribute set, and this tool's output - // schema requires `properties.name`/`properties.feature_type` — so it - // must always be requested, even if the caller's attribute_sets omits - // it, or the API response fails output validation. - const attributeSets = new Set(['basic', ...(input.attribute_sets ?? [])]); - url.searchParams.append( - 'attribute_sets', - Array.from(attributeSets).join(',') - ); - - if (input.language) { - url.searchParams.append('language', input.language); - } - - if (input.worldview) { - url.searchParams.append('worldview', input.worldview); - } - const response = await this.httpRequest(url.toString()); if (!response.ok) { diff --git a/test/tools/place-details-tool/PlaceDetailsTool.test.ts b/test/tools/place-details-tool/PlaceDetailsTool.test.ts index df904d5..8d83787 100644 --- a/test/tools/place-details-tool/PlaceDetailsTool.test.ts +++ b/test/tools/place-details-tool/PlaceDetailsTool.test.ts @@ -11,85 +11,54 @@ import { } from '../../utils/httpPipelineUtils.js'; import { PlaceDetailsTool } from '../../../src/tools/place-details-tool/PlaceDetailsTool.js'; +// Shaped after a live response from places/v1/details/retrieve. const sampleResponse = { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [-122.4194, 37.7749] + mapbox_id: 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY', + name: 'Golden Gate Park', + full_address: 'Golden Gate Park, San Francisco, CA 94117, United States', + brand: null, + primary_category: 'park', + categories: ['park', 'recreation_area'], + permanently_closed: false, + status: 'active', + created_at: '2026-07-02T02:56:22.965', + updated_at: '2026-07-15T01:45:22.019', + score: { closed: 0, popularity: 0.85, reality: 0.9 }, + coordinates: { + latitude: 37.7749, + longitude: -122.4194, + source: 'poi', + routable_points: [ + { name: 'driving', latitude: 37.7748, longitude: -122.4193 } + ] }, - properties: { - name: 'Golden Gate Park', - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB', - feature_type: 'poi', - full_address: 'Golden Gate Park, San Francisco, CA 94117', - place_formatted: 'San Francisco, CA 94117', - poi_category: ['park', 'recreation area'], - poi_category_ids: ['park', 'recreation_area'], - context: { - place: { name: 'San Francisco' }, - region: { name: 'California' }, - country: { name: 'United States', country_code: 'US' } - }, - coordinates: { - longitude: -122.4194, - latitude: 37.7749 - }, - maki: 'park' - } + address: { + city: 'San Francisco', + region: 'California', + country: 'United States', + country_code: 'US' + }, + attributes: {} }; const sampleResponseWithVenue = { ...sampleResponse, - properties: { - ...sampleResponse.properties, - metadata: { - phone: '+1-415-831-2700', - website: 'https://sfrecpark.org/parks/golden-gate-park/', - rating: 4.8, - review_count: 12500, - popularity: 0.92 - } - } + phone: '+1-415-831-2700', + website: 'https://sfrecpark.org/parks/golden-gate-park/' }; -const sampleResponseWithWeekdayText = { +const sampleResponseWithOpeningHours = { ...sampleResponse, - properties: { - ...sampleResponse.properties, - metadata: { - open_hours: { - weekday_text: [ - 'Monday: 9:00 AM – 9:00 PM', - 'Tuesday: 9:00 AM – 9:00 PM', - 'Wednesday: 9:00 AM – 9:00 PM', - 'Thursday: 9:00 AM – 9:00 PM', - 'Friday: 9:00 AM – 10:00 PM', - 'Saturday: 10:00 AM – 10:00 PM', - 'Sunday: Closed' - ] - } - } - } + opening_hours: + 'Mo 09:00-21:00; Tu 09:00-21:00; We 09:00-21:00; Th 09:00-21:00; Fr 09:00-22:00; Sa 10:00-22:00' }; -const sampleResponseWithPeriods = { +const sampleResponseWithPhotos = { ...sampleResponse, - properties: { - ...sampleResponse.properties, - metadata: { - open_hours: { - periods: [ - { open: { day: 1, time: '0900' }, close: { day: 1, time: '2100' } }, - { open: { day: 2, time: '0900' }, close: { day: 2, time: '2100' } }, - { open: { day: 3, time: '0900' }, close: { day: 3, time: '2100' } }, - { open: { day: 4, time: '0900' }, close: { day: 4, time: '2100' } }, - { open: { day: 5, time: '0900' }, close: { day: 5, time: '2200' } }, - { open: { day: 6, time: '1000' }, close: { day: 6, time: '2200' } } - // Sunday (0) absent — should appear as Closed - ] - } - } - } + photos: [ + { url: 'https://example.com/photo1.jpg', width: 800, height: 600 }, + { url: 'https://example.com/photo2.jpg', width: 400, height: 300 } + ] }; describe('PlaceDetailsTool', () => { @@ -103,131 +72,135 @@ describe('PlaceDetailsTool', () => { }); await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB' + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' }); assertHeadersSent(mockHttpRequest); }); - it('constructs correct URL with required parameters', async () => { + it('constructs the correct URL against the Places API', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: async () => sampleResponse }); await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB' + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' }); const calledUrl = mockHttpRequest.mock.calls[0][0]; expect(calledUrl).toContain( - 'search/details/v1/retrieve/dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB' + 'places/v1/details/retrieve/dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' ); expect(calledUrl).toContain('access_token='); - // "basic" is always requested, even when the caller doesn't specify - // attribute_sets at all — see "always includes basic..." tests below. - expect(calledUrl).toContain('attribute_sets=basic'); + // The Places API's Details endpoint has no attribute_sets, language, or + // worldview parameters (unlike the older Details API this tool used + // previously) — it always returns the same shape. + expect(calledUrl).not.toContain('attribute_sets'); expect(calledUrl).not.toContain('language'); expect(calledUrl).not.toContain('worldview'); }); - it('includes optional parameters in URL when provided', async () => { + it('URL-encodes the mapbox_id in the path', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest({ json: async () => sampleResponse }); - await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB', - attribute_sets: ['basic', 'visit', 'venue'], - language: 'fr', - worldview: 'us' - }); + const mapboxId = 'dXJuOm1ieHBvaTpB/special+id'; + await new PlaceDetailsTool({ httpRequest }).run({ mapbox_id: mapboxId }); const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('attribute_sets=basic%2Cvisit%2Cvenue'); - expect(calledUrl).toContain('language=fr'); - expect(calledUrl).toContain('worldview=us'); + expect(calledUrl).toContain(encodeURIComponent(mapboxId)); }); - it('always includes "basic" in the API request even when attribute_sets omits it', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest({ + it('returns formatted text content for valid input', async () => { + const { httpRequest } = setupHttpRequest({ json: async () => sampleResponse }); - await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB', - attribute_sets: ['visit'] + const result = await new PlaceDetailsTool({ httpRequest }).run({ + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('attribute_sets=basic%2Cvisit'); + expect(result.isError).toBe(false); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Name: Golden Gate Park'); + expect(text).toContain( + 'Address: Golden Gate Park, San Francisco, CA 94117, United States' + ); + expect(text).toContain('Coordinates: 37.7749, -122.4194'); + expect(text).toContain('Type: park'); + expect(text).toContain('Category: park, recreation_area'); + expect(text).toContain('Popularity: 85%'); }); - it('does not duplicate "basic" when the caller already includes it', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest({ - json: async () => sampleResponse + it('includes phone and website in formatted text when present', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => sampleResponseWithVenue }); - await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB', - attribute_sets: ['visit', 'basic'] + const result = await new PlaceDetailsTool({ httpRequest }).run({ + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain('attribute_sets=basic%2Cvisit'); - expect(calledUrl).not.toContain('basic%2Cvisit%2Cbasic'); + expect(result.isError).toBe(false); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Phone: +1-415-831-2700'); + expect(text).toContain( + 'Website: https://sfrecpark.org/parks/golden-gate-park/' + ); }); - it('URL-encodes the mapbox_id in the path', async () => { - const { httpRequest, mockHttpRequest } = setupHttpRequest({ - json: async () => sampleResponse + it('notes permanently closed places in formatted text', async () => { + const { httpRequest } = setupHttpRequest({ + json: async () => ({ ...sampleResponse, permanently_closed: true }) }); - const mapboxId = 'dXJuOm1ieHBsYzpB/special+id'; - await new PlaceDetailsTool({ httpRequest }).run({ mapbox_id: mapboxId }); + const result = await new PlaceDetailsTool({ httpRequest }).run({ + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' + }); - const calledUrl = mockHttpRequest.mock.calls[0][0]; - expect(calledUrl).toContain(encodeURIComponent(mapboxId)); + expect(result.isError).toBe(false); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Status: Permanently closed'); }); - it('returns formatted text content for valid input', async () => { + it('formats the opening_hours string into readable lines', async () => { const { httpRequest } = setupHttpRequest({ - json: async () => sampleResponse + json: async () => sampleResponseWithOpeningHours }); const result = await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB' + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' }); expect(result.isError).toBe(false); const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Name: Golden Gate Park'); - expect(text).toContain( - 'Address: Golden Gate Park, San Francisco, CA 94117' - ); - expect(text).toContain('Coordinates: 37.7749, -122.4194'); - expect(text).toContain('Type: poi'); - expect(text).toContain('Category: park, recreation area'); + expect(text).toContain('Hours:'); + expect(text).toContain('Mo 09:00-21:00'); + expect(text).toContain('Sa 10:00-22:00'); }); - it('includes venue metadata in formatted text when present', async () => { + it('lists photo URLs when present', async () => { const { httpRequest } = setupHttpRequest({ - json: async () => sampleResponseWithVenue + json: async () => sampleResponseWithPhotos }); const result = await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB', - attribute_sets: ['basic', 'venue', 'visit'] + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' }); expect(result.isError).toBe(false); const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Phone: +1-415-831-2700'); - expect(text).toContain( - 'Website: https://sfrecpark.org/parks/golden-gate-park/' - ); - expect(text).toContain('Rating: 4.8'); - expect(text).toContain('Reviews: 12500'); - expect(text).toContain('Popularity: 92%'); + expect(text).toContain('Photos:'); + expect(text).toContain('https://example.com/photo1.jpg'); + expect(text).toContain('https://example.com/photo2.jpg'); }); it('returns structured content', async () => { @@ -236,13 +209,14 @@ describe('PlaceDetailsTool', () => { }); const result = await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB' + mapbox_id: + 'dXJuOm1ieHBvaTpmMzRhMDkxOC1kZTRjLTQyNDktODkwNi00ODMxNmUxODMzMzY' }); expect(result.isError).toBe(false); expect(result.structuredContent).toBeDefined(); - expect((result.structuredContent as typeof sampleResponse).type).toBe( - 'Feature' + expect((result.structuredContent as typeof sampleResponse).name).toBe( + 'Golden Gate Park' ); }); @@ -264,12 +238,13 @@ describe('PlaceDetailsTool', () => { ).toContain('Place not found'); }); - it('handles 400 error from invalid mapbox_id', async () => { + it('handles 422 error from invalid mapbox_id format', async () => { const { httpRequest } = setupHttpRequest({ ok: false, - status: 400, - statusText: 'Bad Request', - text: async () => JSON.stringify({ message: 'Invalid mapbox_id format' }) + status: 422, + statusText: 'Unprocessable Entity', + text: async () => + JSON.stringify({ message: 'Invalid mapbox_id format: invalid' }) }); const result = await new PlaceDetailsTool({ httpRequest }).run({ @@ -292,44 +267,6 @@ describe('PlaceDetailsTool', () => { expect(result.isError).toBe(true); }); - it('formats hours using weekday_text when available', async () => { - const { httpRequest } = setupHttpRequest({ - json: async () => sampleResponseWithWeekdayText - }); - - const result = await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB', - attribute_sets: ['visit'] - }); - - expect(result.isError).toBe(false); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Hours:'); - expect(text).toContain('Monday: 9:00 AM – 9:00 PM'); - expect(text).toContain('Sunday: Closed'); - }); - - it('formats hours from periods when weekday_text is absent', async () => { - const { httpRequest } = setupHttpRequest({ - json: async () => sampleResponseWithPeriods - }); - - const result = await new PlaceDetailsTool({ httpRequest }).run({ - mapbox_id: 'dXJuOm1ieHBsYzpBYUFBQUFBQUFBQUFBQUFB', - attribute_sets: ['visit'] - }); - - expect(result.isError).toBe(false); - const text = (result.content[0] as { type: 'text'; text: string }).text; - expect(text).toContain('Hours:'); - expect(text).toContain('Monday: 9 AM – 9 PM'); - expect(text).toContain('Friday: 9 AM – 10 PM'); - expect(text).toContain('Saturday: 10 AM – 10 PM'); - expect(text).toContain('Sunday: Closed'); - // Raw JSON should not appear - expect(text).not.toContain('"day"'); - }); - it('has output schema defined', () => { const { httpRequest } = setupHttpRequest(); const tool = new PlaceDetailsTool({ httpRequest });