diff --git a/README.md b/README.md index 87dd158..99a37b9 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup |------|-------------| | `maps_search_nearby` | Find places near a location by type (restaurant, cafe, hotel, etc.). Supports filtering by radius, rating, and open status. | | `maps_search_places` | Free-text place search (e.g., "sushi restaurants in Tokyo"). Supports location bias, rating, open-now filters. | -| `maps_place_details` | Get full details for a place by its place_id — reviews, phone, website, hours, photos. | +| `maps_place_details` | Get full details for a place by its place_id — reviews, phone, website, hours. Optional `maxPhotos` param returns photo URLs. | | `maps_geocode` | Convert an address or landmark name into GPS coordinates. | | `maps_reverse_geocode` | Convert GPS coordinates into a street address. | | `maps_distance_matrix` | Calculate travel distances and times between multiple origins and destinations. | diff --git a/README.zh-TW.md b/README.zh-TW.md index aef4b5a..2739f0b 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -65,7 +65,7 @@ npx @cablate/mcp-google-map --port 3000 --apikey "YOUR_API_KEY" |------|------| | `maps_search_nearby` | 依類型搜尋附近地點(餐廳、咖啡廳、飯店等),支援半徑、評分、營業中篩選 | | `maps_search_places` | 自然語言地點搜尋(如「東京拉麵」),支援位置偏好、評分、營業中篩選 | -| `maps_place_details` | 以 place_id 取得地點完整資訊 — 評論、電話、網站、營業時間、照片 | +| `maps_place_details` | 以 place_id 取得地點完整資訊 — 評論、電話、網站、營業時間。可選 `maxPhotos` 參數取得照片 URL。 | | `maps_geocode` | 將地址或地標名稱轉換為 GPS 座標 | | `maps_reverse_geocode` | 將 GPS 座標轉換為街道地址 | | `maps_distance_matrix` | 計算多個起點與終點間的旅行距離和時間 | diff --git a/skills/google-maps/SKILL.md b/skills/google-maps/SKILL.md index fb24fa6..bf6ed74 100644 --- a/skills/google-maps/SKILL.md +++ b/skills/google-maps/SKILL.md @@ -44,7 +44,7 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get | `maps_reverse_geocode` | Have coordinates, need an address | "What's at 35.65, 139.74?" | | `maps_search_nearby` | Know a location, find nearby places by type | "Coffee shops near my hotel" | | `maps_search_places` | Natural language place search | "Best ramen in Tokyo" | -| `maps_place_details` | Have a place_id, need full info | "Opening hours and reviews for this restaurant?" | +| `maps_place_details` | Have a place_id, need full info (+ optional photo URLs via `maxPhotos`) | "Opening hours and reviews for this restaurant?" | | `maps_batch_geocode` | Geocode multiple addresses at once (max 50) | "Get coordinates for all these offices" | ### Routing & Distance diff --git a/skills/google-maps/references/tools-api.md b/skills/google-maps/references/tools-api.md index db14207..93cd985 100644 --- a/skills/google-maps/references/tools-api.md +++ b/skills/google-maps/references/tools-api.md @@ -122,15 +122,17 @@ Response: `{ success, data: [{ name, place_id, address, location, rating, total_ ## maps_place_details -Get full details for a place by its place_id (from search results). Returns reviews, phone, website, hours, photos. +Get full details for a place by its place_id (from search results). Returns reviews, phone, website, hours. Set `maxPhotos` to include photo URLs (default: 0 = no photos, saves tokens). ```bash exec maps_place_details '{"placeId": "ChIJCewJkL2LGGAR3Qmk0vCTGkg"}' +exec maps_place_details '{"placeId": "ChIJCewJkL2LGGAR3Qmk0vCTGkg", "maxPhotos": 3}' ``` | Param | Type | Required | Description | |-------|------|----------|-------------| | placeId | string | yes | Google Maps place ID (from search results) | +| maxPhotos | number | no | Number of photo URLs to include (0-10, default 0). Always returns `photo_count`. | --- diff --git a/src/cli.ts b/src/cli.ts index d7f48fe..ee18dbb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -124,7 +124,7 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise< case "place-details": case "get_place_details": case "maps_place_details": - return searcher.getPlaceDetails(params.placeId); + return searcher.getPlaceDetails(params.placeId, params.maxPhotos || 0); case "directions": case "maps_directions": diff --git a/src/services/NewPlacesService.ts b/src/services/NewPlacesService.ts index ad337e0..cd5c8e4 100644 --- a/src/services/NewPlacesService.ts +++ b/src/services/NewPlacesService.ts @@ -142,6 +142,20 @@ export class NewPlacesService { } } + async getPhotoUri(photoName: string, maxWidthPx: number = 800): Promise { + try { + const [response] = await this.client.getPhotoMedia({ + name: `${photoName}/media`, + maxWidthPx, + skipHttpRedirect: true, + }); + return response.photoUri || ""; + } catch (error: any) { + Logger.error("Error in getPhotoUri:", error); + throw new Error(`Failed to get photo URI: ${this.extractErrorMessage(error)}`); + } + } + async getPlaceDetails(placeId: string) { try { const placeName = `places/${placeId}`; diff --git a/src/services/PlacesSearcher.ts b/src/services/PlacesSearcher.ts index cfd4dda..96c273b 100644 --- a/src/services/PlacesSearcher.ts +++ b/src/services/PlacesSearcher.ts @@ -194,10 +194,25 @@ export class PlacesSearcher { } } - async getPlaceDetails(placeId: string): Promise { + async getPlaceDetails(placeId: string, maxPhotos: number = 0): Promise { try { const details = await this.newPlacesService.getPlaceDetails(placeId); + // Resolve photo URLs if requested + let photos: Array<{ url: string; width: number; height: number }> | undefined; + if (maxPhotos > 0 && details.photos?.length > 0) { + const photosToFetch = details.photos.slice(0, maxPhotos); + photos = []; + for (const photo of photosToFetch) { + try { + const url = await this.newPlacesService.getPhotoUri(photo.photo_reference); + photos.push({ url, width: photo.width, height: photo.height }); + } catch { + // Skip failed photos silently + } + } + } + return { success: true, data: { @@ -210,6 +225,8 @@ export class PlacesSearcher { phone: details.formatted_phone_number, website: details.website, price_level: details.price_level, + photo_count: details.photos?.length || 0, + ...(photos && photos.length > 0 ? { photos } : {}), reviews: details.reviews?.map((review: any) => ({ rating: review.rating, text: review.text, diff --git a/src/tools/maps/placeDetails.ts b/src/tools/maps/placeDetails.ts index 639b5cb..a175798 100644 --- a/src/tools/maps/placeDetails.ts +++ b/src/tools/maps/placeDetails.ts @@ -4,20 +4,26 @@ import { getCurrentApiKey } from "../../utils/requestContext.js"; const NAME = "maps_place_details"; const DESCRIPTION = - "Get comprehensive details for a specific place using its Google Maps place_id. Use after search_nearby or maps_search_places to get full information including reviews, phone number, website, opening hours, and photos. Returns everything needed to evaluate or contact a business."; + "Get comprehensive details for a specific place using its Google Maps place_id. Use after search_nearby or maps_search_places to get full information including reviews, phone number, website, and opening hours. Set maxPhotos (1-10) to include photo URLs — omit or set to 0 for no photos (saves tokens)."; const SCHEMA = { placeId: z.string().describe("Google Maps place ID"), + maxPhotos: z + .number() + .int() + .min(0) + .max(10) + .optional() + .describe("Number of photo URLs to include (0 = none, max 10). Omit to skip photos and save tokens."), }; export type PlaceDetailsParams = z.infer>; async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> { try { - // Create a new PlacesSearcher instance with the current request's API key const apiKey = getCurrentApiKey(); const placesSearcher = new PlacesSearcher(apiKey); - const result = await placesSearcher.getPlaceDetails(params.placeId); + const result = await placesSearcher.getPlaceDetails(params.placeId, params.maxPhotos || 0); if (!result.success) { return { diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 1996daa..dc32964 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -508,6 +508,45 @@ async function testToolCalls(session: McpSession): Promise { } } +async function testPlaceDetailsPhotos(session: McpSession): Promise { + console.log("\n🧪 Test 4b: Place details with photos"); + + // First search for a place to get a place_id + const searchResult = await sendRequest(session, "tools/call", { + name: "maps_search_places", + arguments: { query: "Tokyo Tower" }, + }); + const searchContent = searchResult?.result?.content ?? []; + assert(searchContent.length > 0, "Search returns content for place_id"); + const places = JSON.parse(searchContent[0].text); + const placeId = places[0]?.place_id; + assert(typeof placeId === "string" && placeId.length > 0, "Got valid place_id from search"); + + // Test without maxPhotos — should return photo_count but no photos array + const detailsNoPhoto = await sendRequest(session, "tools/call", { + name: "maps_place_details", + arguments: { placeId }, + }); + const noPhotoData = JSON.parse(detailsNoPhoto.result.content[0].text); + assert(typeof noPhotoData.photo_count === "number", "place_details returns photo_count"); + assert(noPhotoData.photos === undefined, "place_details without maxPhotos omits photos array"); + assert(typeof noPhotoData.name === "string", "place_details returns name"); + assert(typeof noPhotoData.rating === "number", "place_details returns rating"); + + // Test with maxPhotos=1 — should return photos array with URLs + const detailsWithPhoto = await sendRequest(session, "tools/call", { + name: "maps_place_details", + arguments: { placeId, maxPhotos: 1 }, + }); + const withPhotoData = JSON.parse(detailsWithPhoto.result.content[0].text); + assert(withPhotoData.photo_count > 0, "place has photos available"); + assert(Array.isArray(withPhotoData.photos), "maxPhotos=1 returns photos array"); + assert(withPhotoData.photos.length === 1, "maxPhotos=1 returns exactly 1 photo"); + assert(withPhotoData.photos[0].url.startsWith("https://"), "photo URL is a valid HTTPS URL"); + assert(typeof withPhotoData.photos[0].width === "number", "photo has width"); + assert(typeof withPhotoData.photos[0].height === "number", "photo has height"); +} + async function testMultiSession(): Promise { console.log("\n🧪 Test 5: Multiple concurrent sessions"); @@ -858,6 +897,7 @@ async function main() { await testListTools(session); await testGeocode(session); await testToolCalls(session); + await testPlaceDetailsPhotos(session); await testMultiSession(); } catch (err) { console.error("\n💥 Fatal error:", err);