Skip to content

Commit bab3e28

Browse files
cablateclaude
andauthored
feat: add batch geocode CLI + MCP tool, tests for new tools (#50)
* feat: add maps_static_map tool — inline map images in chat (#P0) - New tool: maps_static_map returns PNG map as MCP image content - Supports markers, paths, 4 map types (roadmap/satellite/terrain/hybrid) - Auto-fit zoom when markers/path provided without explicit zoom - URL length validation (16,384 char limit) - All 9 files synced per Tool Change Checklist (14 → 15 tools) - Added Visualization category to SKILL.md Tool Map - Added chaining patterns: search→map, directions→map Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add batch-geocode CLI + MCP prompt templates Batch Geocode: - New `batch-geocode` CLI subcommand: reads addresses from file, geocodes in parallel - Configurable concurrency (default 20, max 50) - Supports stdin input (`-i -`) and file output (`-o results.json`) MCP Prompt Templates: - 3 geo prompts: travel-planner, neighborhood-scout, route-optimizer - Registered via McpServer.registerPrompt() — auto-discovers prompts capability - Each prompt returns structured messages teaching AI how to chain geo tools - Available as `/` commands in Claude Desktop and compatible clients Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * revert: remove MCP prompt templates — low client support, skill covers it Prompts primitive is only supported by Claude Desktop, not Cursor/VS Code/most clients. The existing SKILL.md Scenario Recipes already serve the same purpose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add API call tests for air_quality, static_map, batch-geocode - Air quality: MCP tool call + exec mode (Tokyo AQI) - Static map: MCP tool call (image content type, PNG, base64) + exec mode - Batch geocode: CLI subcommand with temp file (2 addresses) - Total: 118 assertions (was ~100) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add maps_batch_geocode MCP tool — geocode up to 50 addresses in one call - New tool: maps_batch_geocode with addresses[] param (max 50) - Parallel geocoding with per-address error handling - Returns { total, succeeded, failed, results[] } - Full test coverage: MCP tool call + exec mode + registration - All 9 files synced per Tool Change Checklist (15 → 16 tools) - 125 smoke test assertions, 0 failures 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 996a4c2 commit bab3e28

10 files changed

Lines changed: 592 additions & 8 deletions

File tree

README.md

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,24 @@
66

77
Give your AI agent the ability to understand the physical world — geocode, route, search, and reason about locations.
88

9-
- **14 tools**11 atomic + 3 composite (explore-area, plan-route, compare-places)
9+
- **16 tools**13 atomic + 3 composite (explore-area, plan-route, compare-places)
1010
- **3 modes** — stdio, StreamableHTTP, standalone exec CLI
1111
- **Agent Skill** — built-in skill definition teaches AI how to chain geo tools ([`skills/google-maps/`](./skills/google-maps/))
1212

1313
### vs Google Grounding Lite
1414

1515
| | This project | [Grounding Lite](https://cloud.google.com/blog/products/ai-machine-learning/announcing-official-mcp-support-for-google-services) |
1616
|---|---|---|
17-
| Tools | **14** | 3 |
17+
| Tools | **16** | 3 |
1818
| Geocoding | Yes | No |
1919
| Step-by-step directions | Yes | No |
2020
| Elevation | Yes | No |
2121
| Distance matrix | Yes | No |
2222
| Place details | Yes | No |
2323
| Timezone | Yes | No |
2424
| Weather | Yes | Yes |
25+
| Air quality | Yes | No |
26+
| Map images | Yes | No |
2527
| Composite tools (explore, plan, compare) | Yes | No |
2628
| Open source | MIT | No |
2729
| Self-hosted | Yes | Google-managed only |
@@ -59,6 +61,8 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup
5961
| `maps_timezone` | Get timezone ID, name, UTC/DST offsets, and local time for coordinates. |
6062
| `maps_weather` | Get current weather conditions or forecast — temperature, humidity, wind, UV, precipitation. |
6163
| `maps_air_quality` | Get air quality index, pollutant concentrations, and health recommendations by demographic group. |
64+
| `maps_static_map` | Generate a map image with markers, paths, or routes — returned inline for the user to see directly. |
65+
| `maps_batch_geocode` | Geocode up to 50 addresses in one call — returns coordinates for each. |
6266
| **Composite Tools** | |
6367
| `maps_explore_area` | Explore what's around a location — searches multiple place types and gets details in one call. |
6468
| `maps_plan_route` | Plan an optimized multi-stop route — geocodes, finds best order, returns directions. |
@@ -112,7 +116,7 @@ Then configure your MCP client:
112116
### Server Information
113117

114118
- **Transport**: stdio (`--stdio`) or Streamable HTTP (default)
115-
- **Tools**: 14 Google Maps tools (11 atomic + 3 composite)
119+
- **Tools**: 16 Google Maps tools (13 atomic + 3 composite)
116120

117121
### CLI Exec Mode (Agent Skill)
118122

@@ -123,7 +127,20 @@ npx @cablate/mcp-google-map exec geocode '{"address":"Tokyo Tower"}'
123127
npx @cablate/mcp-google-map exec search-places '{"query":"ramen in Tokyo"}'
124128
```
125129

126-
All 14 tools available: `geocode`, `reverse-geocode`, `search-nearby`, `search-places`, `place-details`, `directions`, `distance-matrix`, `elevation`, `timezone`, `weather`, `air-quality`, `explore-area`, `plan-route`, `compare-places`. See [`skills/google-maps/`](./skills/google-maps/) for the agent skill definition and full parameter docs.
130+
All 16 tools available: `geocode`, `reverse-geocode`, `search-nearby`, `search-places`, `place-details`, `directions`, `distance-matrix`, `elevation`, `timezone`, `weather`, `air-quality`, `static-map`, `batch-geocode-tool`, `explore-area`, `plan-route`, `compare-places`. See [`skills/google-maps/`](./skills/google-maps/) for the agent skill definition and full parameter docs.
131+
132+
### Batch Geocode
133+
134+
Geocode hundreds of addresses from a file:
135+
136+
```bash
137+
npx @cablate/mcp-google-map batch-geocode -i addresses.txt -o results.json
138+
cat addresses.txt | npx @cablate/mcp-google-map batch-geocode -i -
139+
```
140+
141+
Input: one address per line. Output: JSON with `{ total, succeeded, failed, results[] }`. Default concurrency: 20 parallel requests.
142+
143+
127144

128145
### API Key Configuration
129146

@@ -217,6 +234,8 @@ src/
217234
│ ├── timezone.ts # maps_timezone tool
218235
│ ├── weather.ts # maps_weather tool
219236
│ ├── airQuality.ts # maps_air_quality tool
237+
│ ├── staticMap.ts # maps_static_map tool
238+
│ ├── batchGeocode.ts # maps_batch_geocode tool
220239
│ ├── exploreArea.ts # maps_explore_area (composite)
221240
│ ├── planRoute.ts # maps_plan_route (composite)
222241
│ └── comparePlaces.ts # maps_compare_places (composite)
@@ -257,11 +276,11 @@ For enterprise security reviews, see [Security Assessment Clarifications](./SECU
257276

258277
| Tool | What it unlocks | Status |
259278
|------|----------------|--------|
260-
| `maps_static_map` | Return map images with pins/routes — multimodal AI can "see" the map | Planned |
279+
| `maps_static_map` | Return map images with pins/routes — multimodal AI can "see" the map | **Done** |
261280
| `maps_air_quality` | AQI, pollutants — health-aware travel, outdoor planning, real estate | **Done** |
262281
| `maps_validate_address` | Standardize and verify addresses — logistics/e-commerce | Planned |
263282
| `maps_isochrone` | "Show me everything within 30 min drive" — reachability analysis | Planned |
264-
| `maps_batch_geocode` | Geocode hundreds of addresses in one call — data enrichment | Planned |
283+
| `maps_batch_geocode` | Geocode hundreds of addresses in one call — data enrichment | **Done** (CLI) |
265284

266285
### Capabilities
267286

skills/google-maps/SKILL.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get
3535

3636
## Tool Map
3737

38-
14 tools in four categories — pick by scenario:
38+
16 tools in five categories — pick by scenario:
3939

4040
### Place Discovery
4141
| Tool | When to use | Example |
@@ -45,6 +45,7 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get
4545
| `search-nearby` | Know a location, find nearby places by type | "Coffee shops near my hotel" |
4646
| `search-places` | Natural language place search | "Best ramen in Tokyo" |
4747
| `place-details` | Have a place_id, need full info | "Opening hours and reviews for this restaurant?" |
48+
| `batch-geocode` | Geocode multiple addresses at once (max 50) | "Get coordinates for all these offices" |
4849

4950
### Routing & Distance
5051
| Tool | When to use | Example |
@@ -60,6 +61,11 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get
6061
| `weather` | Weather at a location (current or forecast) | "What's the weather in Paris?" |
6162
| `air-quality` | AQI, pollutants, health recommendations | "Is the air safe for jogging?" |
6263

64+
### Visualization
65+
| Tool | When to use | Example |
66+
|------|-------------|---------|
67+
| `static-map` | Show locations/routes on a map image | "Show me these places on a map" |
68+
6369
### Composite (one-call shortcuts)
6470
| Tool | When to use | Example |
6571
|------|-------------|---------|

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@ Response:
2626

2727
---
2828

29+
## batch-geocode
30+
31+
Geocode multiple addresses in one call (max 50).
32+
33+
```bash
34+
exec batch-geocode-tool '{"addresses": ["Tokyo Tower", "Eiffel Tower", "Statue of Liberty"]}'
35+
```
36+
37+
| Param | Type | Required | Description |
38+
|-------|------|----------|-------------|
39+
| addresses | string[] | yes | List of addresses or landmarks (max 50) |
40+
41+
Response:
42+
```json
43+
{
44+
"total": 3,
45+
"succeeded": 3,
46+
"failed": 0,
47+
"results": [
48+
{ "address": "Tokyo Tower", "success": true, "data": { "location": { "lat": 35.658, "lng": 139.745 }, "formatted_address": "..." } },
49+
...
50+
]
51+
}
52+
```
53+
54+
---
55+
2956
## reverse-geocode
3057

3158
Convert GPS coordinates to a street address.
@@ -238,6 +265,35 @@ Chaining: `geocode` → `air-quality` when the user gives an address instead of
238265

239266
---
240267

268+
## static-map
269+
270+
Generate a map image with markers, paths, or routes. Returns an inline PNG image.
271+
272+
```bash
273+
exec static-map '{"center": "Tokyo Tower", "zoom": 14}'
274+
exec static-map '{"markers": ["color:red|label:A|35.6586,139.7454", "color:blue|label:B|35.6595,139.7006"]}'
275+
exec static-map '{"markers": ["color:red|35.6586,139.7454"], "maptype": "satellite", "zoom": 16}'
276+
```
277+
278+
| Param | Type | Required | Description |
279+
|-------|------|----------|-------------|
280+
| center | string | no | "lat,lng" or address. Optional if markers/path provided. |
281+
| zoom | number | no | 0-21 (auto-fit if omitted) |
282+
| size | string | no | "WxH" pixels. Default: "600x400". Max: "640x640" |
283+
| maptype | string | no | roadmap, satellite, terrain, hybrid. Default: roadmap |
284+
| markers | string[] | no | Marker descriptors: "color:red\|label:A\|lat,lng" |
285+
| path | string[] | no | Path descriptors: "color:0x0000ff\|weight:3\|lat1,lng1\|lat2,lng2" |
286+
287+
Response: MCP image content (inline PNG) + size metadata.
288+
289+
Chaining patterns:
290+
- `search-nearby``static-map` (mark found places on map)
291+
- `plan-route` / `directions``static-map` (draw the route with path + markers)
292+
- `explore-area``static-map` (visualize neighborhood search results)
293+
- `compare-places``static-map` (show compared places side by side)
294+
295+
---
296+
241297
## explore-area (composite)
242298

243299
Explore a neighborhood in one call. Internally chains geocode → search-nearby (per type) → place-details (top N).
@@ -314,6 +370,18 @@ geocode {"address":"Tokyo"}
314370
air-quality {"latitude":35.6762,"longitude":139.6503}
315371
```
316372

373+
**Search → Map** — Find places, then show them on a map.
374+
```
375+
search-nearby {"center":{"value":"35.6586,139.7454","isCoordinates":true},"keyword":"cafe","radius":500}
376+
static-map {"markers":["color:red|label:1|lat1,lng1","color:red|label:2|lat2,lng2"]}
377+
```
378+
379+
**Directions → Map** — Get a route, then visualize it.
380+
```
381+
directions {"origin":"Tokyo Tower","destination":"Shibuya Station","mode":"walking"}
382+
static-map {"path":["color:0x4285F4|weight:4|lat1,lng1|lat2,lng2|..."],"markers":["color:green|label:A|origin","color:red|label:B|dest"]}
383+
```
384+
317385
---
318386

319387
## Scenario Recipes

src/cli.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { Logger } from "./index.js";
1010
import { PlacesSearcher } from "./services/PlacesSearcher.js";
1111
import { fileURLToPath } from "url";
1212
import { dirname } from "path";
13-
import { readFileSync } from "fs";
13+
import { readFileSync, writeFileSync, existsSync } from "fs";
14+
import { createInterface } from "readline";
1415

1516
// Get the directory of the current module
1617
const __filename = fileURLToPath(import.meta.url);
@@ -90,6 +91,8 @@ const EXEC_TOOLS = [
9091
"plan-route",
9192
"compare-places",
9293
"air-quality",
94+
"static-map",
95+
"batch-geocode-tool",
9396
] as const;
9497

9598
async function execTool(toolName: string, params: any, apiKey: string): Promise<any> {
@@ -177,6 +180,26 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise<
177180
params.includePollutants
178181
);
179182

183+
case "static-map":
184+
case "maps_static_map":
185+
return searcher.getStaticMap(params);
186+
187+
case "batch-geocode-tool":
188+
case "maps_batch_geocode": {
189+
const results = await Promise.all(
190+
(params.addresses as string[]).map(async (address: string) => {
191+
try {
192+
const result = await searcher.geocode(address);
193+
return { address, ...result };
194+
} catch (error: any) {
195+
return { address, success: false, error: error.message };
196+
}
197+
})
198+
);
199+
const succeeded = results.filter((r) => r.success).length;
200+
return { success: true, data: { total: params.addresses.length, succeeded, failed: params.addresses.length - succeeded, results } };
201+
}
202+
180203
default:
181204
throw new Error(`Unknown tool: ${toolName}. Available: ${EXEC_TOOLS.join(", ")}`);
182205
}
@@ -257,6 +280,127 @@ if (isRunDirectly || isMainModule) {
257280
}
258281
}
259282
)
283+
.command(
284+
"batch-geocode",
285+
"Geocode multiple addresses from a file (one address per line)",
286+
(yargs) => {
287+
return yargs
288+
.option("input", {
289+
alias: "i",
290+
type: "string",
291+
describe: "Input file path (one address per line). Use - for stdin.",
292+
demandOption: true,
293+
})
294+
.option("output", {
295+
alias: "o",
296+
type: "string",
297+
describe: "Output file path (JSON). Defaults to stdout.",
298+
})
299+
.option("concurrency", {
300+
alias: "c",
301+
type: "number",
302+
describe: "Max parallel requests",
303+
default: 20,
304+
})
305+
.option("apikey", {
306+
alias: "k",
307+
type: "string",
308+
description: "Google Maps API key",
309+
default: process.env.GOOGLE_MAPS_API_KEY,
310+
})
311+
.example([
312+
["$0 batch-geocode -i addresses.txt", "Geocode to stdout"],
313+
["$0 batch-geocode -i addresses.txt -o results.json", "Geocode to file"],
314+
["cat addresses.txt | $0 batch-geocode -i -", "Geocode from stdin"],
315+
]);
316+
},
317+
async (argv) => {
318+
if (!argv.apikey) {
319+
console.error("Error: GOOGLE_MAPS_API_KEY not set. Use --apikey or set env var.");
320+
process.exit(1);
321+
}
322+
323+
// Read addresses
324+
let lines: string[];
325+
if (argv.input === "-") {
326+
// Read from stdin
327+
const rl = createInterface({ input: process.stdin });
328+
lines = [];
329+
for await (const line of rl) {
330+
const trimmed = line.trim();
331+
if (trimmed) lines.push(trimmed);
332+
}
333+
} else {
334+
if (!existsSync(argv.input as string)) {
335+
console.error(`Error: File not found: ${argv.input}`);
336+
process.exit(1);
337+
}
338+
lines = readFileSync(argv.input as string, "utf-8")
339+
.split("\n")
340+
.map((l) => l.trim())
341+
.filter((l) => l.length > 0);
342+
}
343+
344+
if (lines.length === 0) {
345+
console.error("Error: No addresses found in input.");
346+
process.exit(1);
347+
}
348+
349+
const searcher = new PlacesSearcher(argv.apikey as string);
350+
const concurrency = Math.min(Math.max(argv.concurrency as number, 1), 50);
351+
const results: any[] = [];
352+
let completed = 0;
353+
354+
// Process with concurrency limit
355+
const semaphore = async (tasks: (() => Promise<void>)[], limit: number) => {
356+
const executing: Promise<void>[] = [];
357+
for (const task of tasks) {
358+
const p = task().then(() => {
359+
executing.splice(executing.indexOf(p), 1);
360+
});
361+
executing.push(p);
362+
if (executing.length >= limit) {
363+
await Promise.race(executing);
364+
}
365+
}
366+
await Promise.all(executing);
367+
};
368+
369+
const tasks = lines.map((address, index) => async () => {
370+
try {
371+
const result = await searcher.geocode(address);
372+
results[index] = { address, ...result };
373+
} catch (error: any) {
374+
results[index] = { address, success: false, error: error.message };
375+
}
376+
completed++;
377+
if (!argv.output) return; // Don't log progress when outputting to stdout
378+
process.stderr.write(`\r ${completed}/${lines.length} geocoded`);
379+
});
380+
381+
await semaphore(tasks, concurrency);
382+
383+
if (argv.output) {
384+
process.stderr.write("\n");
385+
}
386+
387+
// Summary
388+
const succeeded = results.filter((r) => r.success).length;
389+
const failed = results.filter((r) => !r.success).length;
390+
const summary = { total: lines.length, succeeded, failed, results };
391+
392+
const json = JSON.stringify(summary, null, 2);
393+
394+
if (argv.output) {
395+
writeFileSync(argv.output as string, json, "utf-8");
396+
console.error(`Done: ${succeeded}/${lines.length} succeeded. Output: ${argv.output}`);
397+
} else {
398+
console.log(json);
399+
}
400+
401+
process.exit(failed > 0 ? 1 : 0);
402+
}
403+
)
260404
.command(
261405
"$0",
262406
"Start the MCP server (HTTP by default, --stdio for stdio mode)",

0 commit comments

Comments
 (0)