Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 計算多個起點與終點間的旅行距離和時間 |
Expand Down
2 changes: 1 addition & 1 deletion skills/google-maps/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion skills/google-maps/references/tools-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

---

Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"search-along-route",
] as const;

async function execTool(toolName: string, params: any, apiKey: string): Promise<any> {

Check warning on line 97 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

Check warning on line 97 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const searcher = new PlacesSearcher(apiKey);

switch (toolName) {
Expand Down Expand Up @@ -124,7 +124,7 @@
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":
Expand Down Expand Up @@ -190,7 +190,7 @@
try {
const result = await searcher.geocode(address);
return { address, ...result };
} catch (error: any) {

Check warning on line 193 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
return { address, success: false, error: error.message };
}
})
Expand Down Expand Up @@ -229,7 +229,7 @@
const packageJsonPath = resolve(__dirname, "../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
packageVersion = packageJson.version;
} catch (e) {

Check warning on line 232 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

'e' is defined but never used
packageVersion = "0.0.0";
}

Expand Down Expand Up @@ -280,7 +280,7 @@
const result = await execTool(argv.tool as string, params, argv.apikey as string);
console.log(JSON.stringify(result, null, 2));
process.exit(0);
} catch (error: any) {

Check warning on line 283 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
console.error(JSON.stringify({ error: error.message }, null, 2));
process.exit(1);
}
Expand Down Expand Up @@ -354,7 +354,7 @@

const searcher = new PlacesSearcher(argv.apikey as string);
const concurrency = Math.min(Math.max(argv.concurrency as number, 1), 50);
const results: any[] = [];

Check warning on line 357 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
let completed = 0;

// Process with concurrency limit
Expand All @@ -376,7 +376,7 @@
try {
const result = await searcher.geocode(address);
results[index] = { address, ...result };
} catch (error: any) {

Check warning on line 379 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
results[index] = { address, success: false, error: error.message };
}
completed++;
Expand Down
14 changes: 14 additions & 0 deletions src/services/NewPlacesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ export class NewPlacesService {
}
}

async getPhotoUri(photoName: string, maxWidthPx: number = 800): Promise<string> {
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}`;
Expand Down
19 changes: 18 additions & 1 deletion src/services/PlacesSearcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,25 @@ export class PlacesSearcher {
}
}

async getPlaceDetails(placeId: string): Promise<PlaceDetailsResponse> {
async getPlaceDetails(placeId: string, maxPhotos: number = 0): Promise<PlaceDetailsResponse> {
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: {
Expand All @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions src/tools/maps/placeDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<z.ZodObject<typeof SCHEMA>>;

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 {
Expand Down
40 changes: 40 additions & 0 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,45 @@ async function testToolCalls(session: McpSession): Promise<void> {
}
}

async function testPlaceDetailsPhotos(session: McpSession): Promise<void> {
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<void> {
console.log("\n🧪 Test 5: Multiple concurrent sessions");

Expand Down Expand Up @@ -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);
Expand Down
Loading