Skip to content

Commit 13afbd9

Browse files
cablateclaude
andauthored
feat: add photo URLs to maps_place_details (#59)
* feat: add photo URLs to maps_place_details via maxPhotos param - Add optional maxPhotos param (0-10, default 0) to maps_place_details - Default returns photo_count only (no extra tokens) - When maxPhotos > 0, resolves actual photo URLs via getPhotoMedia API - Add getPhotoUri method to NewPlacesService - Update CLI exec to pass maxPhotos param - Update tool description to document photo behavior Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: update skill docs and READMEs for maxPhotos param Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add place_details photo tests (147 → 159) - Test photo_count always returned - Test no photos array when maxPhotos omitted - Test maxPhotos=1 returns valid HTTPS photo URL with dimensions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ffff728 commit 13afbd9

File tree

9 files changed

+88
-9
lines changed

9 files changed

+88
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup
6565
|------|-------------|
6666
| `maps_search_nearby` | Find places near a location by type (restaurant, cafe, hotel, etc.). Supports filtering by radius, rating, and open status. |
6767
| `maps_search_places` | Free-text place search (e.g., "sushi restaurants in Tokyo"). Supports location bias, rating, open-now filters. |
68-
| `maps_place_details` | Get full details for a place by its place_id — reviews, phone, website, hours, photos. |
68+
| `maps_place_details` | Get full details for a place by its place_id — reviews, phone, website, hours. Optional `maxPhotos` param returns photo URLs. |
6969
| `maps_geocode` | Convert an address or landmark name into GPS coordinates. |
7070
| `maps_reverse_geocode` | Convert GPS coordinates into a street address. |
7171
| `maps_distance_matrix` | Calculate travel distances and times between multiple origins and destinations. |

README.zh-TW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ npx @cablate/mcp-google-map --port 3000 --apikey "YOUR_API_KEY"
6565
|------|------|
6666
| `maps_search_nearby` | 依類型搜尋附近地點(餐廳、咖啡廳、飯店等),支援半徑、評分、營業中篩選 |
6767
| `maps_search_places` | 自然語言地點搜尋(如「東京拉麵」),支援位置偏好、評分、營業中篩選 |
68-
| `maps_place_details` | 以 place_id 取得地點完整資訊 — 評論、電話、網站、營業時間、照片 |
68+
| `maps_place_details` | 以 place_id 取得地點完整資訊 — 評論、電話、網站、營業時間。可選 `maxPhotos` 參數取得照片 URL。 |
6969
| `maps_geocode` | 將地址或地標名稱轉換為 GPS 座標 |
7070
| `maps_reverse_geocode` | 將 GPS 座標轉換為街道地址 |
7171
| `maps_distance_matrix` | 計算多個起點與終點間的旅行距離和時間 |

skills/google-maps/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get
4444
| `maps_reverse_geocode` | Have coordinates, need an address | "What's at 35.65, 139.74?" |
4545
| `maps_search_nearby` | Know a location, find nearby places by type | "Coffee shops near my hotel" |
4646
| `maps_search_places` | Natural language place search | "Best ramen in Tokyo" |
47-
| `maps_place_details` | Have a place_id, need full info | "Opening hours and reviews for this restaurant?" |
47+
| `maps_place_details` | Have a place_id, need full info (+ optional photo URLs via `maxPhotos`) | "Opening hours and reviews for this restaurant?" |
4848
| `maps_batch_geocode` | Geocode multiple addresses at once (max 50) | "Get coordinates for all these offices" |
4949

5050
### Routing & Distance

skills/google-maps/references/tools-api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,17 @@ Response: `{ success, data: [{ name, place_id, address, location, rating, total_
122122

123123
## maps_place_details
124124

125-
Get full details for a place by its place_id (from search results). Returns reviews, phone, website, hours, photos.
125+
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).
126126

127127
```bash
128128
exec maps_place_details '{"placeId": "ChIJCewJkL2LGGAR3Qmk0vCTGkg"}'
129+
exec maps_place_details '{"placeId": "ChIJCewJkL2LGGAR3Qmk0vCTGkg", "maxPhotos": 3}'
129130
```
130131

131132
| Param | Type | Required | Description |
132133
|-------|------|----------|-------------|
133134
| placeId | string | yes | Google Maps place ID (from search results) |
135+
| maxPhotos | number | no | Number of photo URLs to include (0-10, default 0). Always returns `photo_count`. |
134136

135137
---
136138

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise<
124124
case "place-details":
125125
case "get_place_details":
126126
case "maps_place_details":
127-
return searcher.getPlaceDetails(params.placeId);
127+
return searcher.getPlaceDetails(params.placeId, params.maxPhotos || 0);
128128

129129
case "directions":
130130
case "maps_directions":

src/services/NewPlacesService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@ export class NewPlacesService {
142142
}
143143
}
144144

145+
async getPhotoUri(photoName: string, maxWidthPx: number = 800): Promise<string> {
146+
try {
147+
const [response] = await this.client.getPhotoMedia({
148+
name: `${photoName}/media`,
149+
maxWidthPx,
150+
skipHttpRedirect: true,
151+
});
152+
return response.photoUri || "";
153+
} catch (error: any) {
154+
Logger.error("Error in getPhotoUri:", error);
155+
throw new Error(`Failed to get photo URI: ${this.extractErrorMessage(error)}`);
156+
}
157+
}
158+
145159
async getPlaceDetails(placeId: string) {
146160
try {
147161
const placeName = `places/${placeId}`;

src/services/PlacesSearcher.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,25 @@ export class PlacesSearcher {
194194
}
195195
}
196196

197-
async getPlaceDetails(placeId: string): Promise<PlaceDetailsResponse> {
197+
async getPlaceDetails(placeId: string, maxPhotos: number = 0): Promise<PlaceDetailsResponse> {
198198
try {
199199
const details = await this.newPlacesService.getPlaceDetails(placeId);
200200

201+
// Resolve photo URLs if requested
202+
let photos: Array<{ url: string; width: number; height: number }> | undefined;
203+
if (maxPhotos > 0 && details.photos?.length > 0) {
204+
const photosToFetch = details.photos.slice(0, maxPhotos);
205+
photos = [];
206+
for (const photo of photosToFetch) {
207+
try {
208+
const url = await this.newPlacesService.getPhotoUri(photo.photo_reference);
209+
photos.push({ url, width: photo.width, height: photo.height });
210+
} catch {
211+
// Skip failed photos silently
212+
}
213+
}
214+
}
215+
201216
return {
202217
success: true,
203218
data: {
@@ -210,6 +225,8 @@ export class PlacesSearcher {
210225
phone: details.formatted_phone_number,
211226
website: details.website,
212227
price_level: details.price_level,
228+
photo_count: details.photos?.length || 0,
229+
...(photos && photos.length > 0 ? { photos } : {}),
213230
reviews: details.reviews?.map((review: any) => ({
214231
rating: review.rating,
215232
text: review.text,

src/tools/maps/placeDetails.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,26 @@ import { getCurrentApiKey } from "../../utils/requestContext.js";
44

55
const NAME = "maps_place_details";
66
const DESCRIPTION =
7-
"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.";
7+
"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).";
88

99
const SCHEMA = {
1010
placeId: z.string().describe("Google Maps place ID"),
11+
maxPhotos: z
12+
.number()
13+
.int()
14+
.min(0)
15+
.max(10)
16+
.optional()
17+
.describe("Number of photo URLs to include (0 = none, max 10). Omit to skip photos and save tokens."),
1118
};
1219

1320
export type PlaceDetailsParams = z.infer<z.ZodObject<typeof SCHEMA>>;
1421

1522
async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
1623
try {
17-
// Create a new PlacesSearcher instance with the current request's API key
1824
const apiKey = getCurrentApiKey();
1925
const placesSearcher = new PlacesSearcher(apiKey);
20-
const result = await placesSearcher.getPlaceDetails(params.placeId);
26+
const result = await placesSearcher.getPlaceDetails(params.placeId, params.maxPhotos || 0);
2127

2228
if (!result.success) {
2329
return {

tests/smoke.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,45 @@ async function testToolCalls(session: McpSession): Promise<void> {
508508
}
509509
}
510510

511+
async function testPlaceDetailsPhotos(session: McpSession): Promise<void> {
512+
console.log("\n🧪 Test 4b: Place details with photos");
513+
514+
// First search for a place to get a place_id
515+
const searchResult = await sendRequest(session, "tools/call", {
516+
name: "maps_search_places",
517+
arguments: { query: "Tokyo Tower" },
518+
});
519+
const searchContent = searchResult?.result?.content ?? [];
520+
assert(searchContent.length > 0, "Search returns content for place_id");
521+
const places = JSON.parse(searchContent[0].text);
522+
const placeId = places[0]?.place_id;
523+
assert(typeof placeId === "string" && placeId.length > 0, "Got valid place_id from search");
524+
525+
// Test without maxPhotos — should return photo_count but no photos array
526+
const detailsNoPhoto = await sendRequest(session, "tools/call", {
527+
name: "maps_place_details",
528+
arguments: { placeId },
529+
});
530+
const noPhotoData = JSON.parse(detailsNoPhoto.result.content[0].text);
531+
assert(typeof noPhotoData.photo_count === "number", "place_details returns photo_count");
532+
assert(noPhotoData.photos === undefined, "place_details without maxPhotos omits photos array");
533+
assert(typeof noPhotoData.name === "string", "place_details returns name");
534+
assert(typeof noPhotoData.rating === "number", "place_details returns rating");
535+
536+
// Test with maxPhotos=1 — should return photos array with URLs
537+
const detailsWithPhoto = await sendRequest(session, "tools/call", {
538+
name: "maps_place_details",
539+
arguments: { placeId, maxPhotos: 1 },
540+
});
541+
const withPhotoData = JSON.parse(detailsWithPhoto.result.content[0].text);
542+
assert(withPhotoData.photo_count > 0, "place has photos available");
543+
assert(Array.isArray(withPhotoData.photos), "maxPhotos=1 returns photos array");
544+
assert(withPhotoData.photos.length === 1, "maxPhotos=1 returns exactly 1 photo");
545+
assert(withPhotoData.photos[0].url.startsWith("https://"), "photo URL is a valid HTTPS URL");
546+
assert(typeof withPhotoData.photos[0].width === "number", "photo has width");
547+
assert(typeof withPhotoData.photos[0].height === "number", "photo has height");
548+
}
549+
511550
async function testMultiSession(): Promise<void> {
512551
console.log("\n🧪 Test 5: Multiple concurrent sessions");
513552

@@ -858,6 +897,7 @@ async function main() {
858897
await testListTools(session);
859898
await testGeocode(session);
860899
await testToolCalls(session);
900+
await testPlaceDetailsPhotos(session);
861901
await testMultiSession();
862902
} catch (err) {
863903
console.error("\n💥 Fatal error:", err);

0 commit comments

Comments
 (0)