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
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@

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

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

### vs Google Grounding Lite

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

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

### CLI Exec Mode (Agent Skill)

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

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.
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.

### Batch Geocode

Geocode hundreds of addresses from a file:

```bash
npx @cablate/mcp-google-map batch-geocode -i addresses.txt -o results.json
cat addresses.txt | npx @cablate/mcp-google-map batch-geocode -i -
```

Input: one address per line. Output: JSON with `{ total, succeeded, failed, results[] }`. Default concurrency: 20 parallel requests.



### API Key Configuration

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

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

### Capabilities

Expand Down
8 changes: 7 additions & 1 deletion skills/google-maps/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get

## Tool Map

14 tools in four categories — pick by scenario:
16 tools in five categories — pick by scenario:

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

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

### Visualization
| Tool | When to use | Example |
|------|-------------|---------|
| `static-map` | Show locations/routes on a map image | "Show me these places on a map" |

### Composite (one-call shortcuts)
| Tool | When to use | Example |
|------|-------------|---------|
Expand Down
68 changes: 68 additions & 0 deletions skills/google-maps/references/tools-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,33 @@ Response:

---

## batch-geocode

Geocode multiple addresses in one call (max 50).

```bash
exec batch-geocode-tool '{"addresses": ["Tokyo Tower", "Eiffel Tower", "Statue of Liberty"]}'
```

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| addresses | string[] | yes | List of addresses or landmarks (max 50) |

Response:
```json
{
"total": 3,
"succeeded": 3,
"failed": 0,
"results": [
{ "address": "Tokyo Tower", "success": true, "data": { "location": { "lat": 35.658, "lng": 139.745 }, "formatted_address": "..." } },
...
]
}
```

---

## reverse-geocode

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

---

## static-map

Generate a map image with markers, paths, or routes. Returns an inline PNG image.

```bash
exec static-map '{"center": "Tokyo Tower", "zoom": 14}'
exec static-map '{"markers": ["color:red|label:A|35.6586,139.7454", "color:blue|label:B|35.6595,139.7006"]}'
exec static-map '{"markers": ["color:red|35.6586,139.7454"], "maptype": "satellite", "zoom": 16}'
```

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| center | string | no | "lat,lng" or address. Optional if markers/path provided. |
| zoom | number | no | 0-21 (auto-fit if omitted) |
| size | string | no | "WxH" pixels. Default: "600x400". Max: "640x640" |
| maptype | string | no | roadmap, satellite, terrain, hybrid. Default: roadmap |
| markers | string[] | no | Marker descriptors: "color:red\|label:A\|lat,lng" |
| path | string[] | no | Path descriptors: "color:0x0000ff\|weight:3\|lat1,lng1\|lat2,lng2" |

Response: MCP image content (inline PNG) + size metadata.

Chaining patterns:
- `search-nearby` → `static-map` (mark found places on map)
- `plan-route` / `directions` → `static-map` (draw the route with path + markers)
- `explore-area` → `static-map` (visualize neighborhood search results)
- `compare-places` → `static-map` (show compared places side by side)

---

## explore-area (composite)

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

**Search → Map** — Find places, then show them on a map.
```
search-nearby {"center":{"value":"35.6586,139.7454","isCoordinates":true},"keyword":"cafe","radius":500}
static-map {"markers":["color:red|label:1|lat1,lng1","color:red|label:2|lat2,lng2"]}
```

**Directions → Map** — Get a route, then visualize it.
```
directions {"origin":"Tokyo Tower","destination":"Shibuya Station","mode":"walking"}
static-map {"path":["color:0x4285F4|weight:4|lat1,lng1|lat2,lng2|..."],"markers":["color:green|label:A|origin","color:red|label:B|dest"]}
```

---

## Scenario Recipes
Expand Down
146 changes: 145 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { Logger } from "./index.js";
import { PlacesSearcher } from "./services/PlacesSearcher.js";
import { fileURLToPath } from "url";
import { dirname } from "path";
import { readFileSync } from "fs";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { createInterface } from "readline";

// Get the directory of the current module
const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -90,6 +91,8 @@ const EXEC_TOOLS = [
"plan-route",
"compare-places",
"air-quality",
"static-map",
"batch-geocode-tool",
] as const;

async function execTool(toolName: string, params: any, apiKey: string): Promise<any> {
Expand Down Expand Up @@ -177,6 +180,26 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise<
params.includePollutants
);

case "static-map":
case "maps_static_map":
return searcher.getStaticMap(params);

case "batch-geocode-tool":
case "maps_batch_geocode": {
const results = await Promise.all(
(params.addresses as string[]).map(async (address: string) => {
try {
const result = await searcher.geocode(address);
return { address, ...result };
} catch (error: any) {
return { address, success: false, error: error.message };
}
})
);
const succeeded = results.filter((r) => r.success).length;
return { success: true, data: { total: params.addresses.length, succeeded, failed: params.addresses.length - succeeded, results } };
}

default:
throw new Error(`Unknown tool: ${toolName}. Available: ${EXEC_TOOLS.join(", ")}`);
}
Expand Down Expand Up @@ -257,6 +280,127 @@ if (isRunDirectly || isMainModule) {
}
}
)
.command(
"batch-geocode",
"Geocode multiple addresses from a file (one address per line)",
(yargs) => {
return yargs
.option("input", {
alias: "i",
type: "string",
describe: "Input file path (one address per line). Use - for stdin.",
demandOption: true,
})
.option("output", {
alias: "o",
type: "string",
describe: "Output file path (JSON). Defaults to stdout.",
})
.option("concurrency", {
alias: "c",
type: "number",
describe: "Max parallel requests",
default: 20,
})
.option("apikey", {
alias: "k",
type: "string",
description: "Google Maps API key",
default: process.env.GOOGLE_MAPS_API_KEY,
})
.example([
["$0 batch-geocode -i addresses.txt", "Geocode to stdout"],
["$0 batch-geocode -i addresses.txt -o results.json", "Geocode to file"],
["cat addresses.txt | $0 batch-geocode -i -", "Geocode from stdin"],
]);
},
async (argv) => {
if (!argv.apikey) {
console.error("Error: GOOGLE_MAPS_API_KEY not set. Use --apikey or set env var.");
process.exit(1);
}

// Read addresses
let lines: string[];
if (argv.input === "-") {
// Read from stdin
const rl = createInterface({ input: process.stdin });
lines = [];
for await (const line of rl) {
const trimmed = line.trim();
if (trimmed) lines.push(trimmed);
}
} else {
if (!existsSync(argv.input as string)) {
console.error(`Error: File not found: ${argv.input}`);
process.exit(1);
}
lines = readFileSync(argv.input as string, "utf-8")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
}

if (lines.length === 0) {
console.error("Error: No addresses found in input.");
process.exit(1);
}

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

// Process with concurrency limit
const semaphore = async (tasks: (() => Promise<void>)[], limit: number) => {
const executing: Promise<void>[] = [];
for (const task of tasks) {
const p = task().then(() => {
executing.splice(executing.indexOf(p), 1);
});
executing.push(p);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
await Promise.all(executing);
};

const tasks = lines.map((address, index) => async () => {
try {
const result = await searcher.geocode(address);
results[index] = { address, ...result };
} catch (error: any) {
results[index] = { address, success: false, error: error.message };
}
completed++;
if (!argv.output) return; // Don't log progress when outputting to stdout
process.stderr.write(`\r ${completed}/${lines.length} geocoded`);
});

await semaphore(tasks, concurrency);

if (argv.output) {
process.stderr.write("\n");
}

// Summary
const succeeded = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
const summary = { total: lines.length, succeeded, failed, results };

const json = JSON.stringify(summary, null, 2);

if (argv.output) {
writeFileSync(argv.output as string, json, "utf-8");
console.error(`Done: ${succeeded}/${lines.length} succeeded. Output: ${argv.output}`);
} else {
console.log(json);
}

process.exit(failed > 0 ? 1 : 0);
}
)
.command(
"$0",
"Start the MCP server (HTTP by default, --stdio for stdio mode)",
Expand Down
Loading