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

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

- **13 tools** — 10 atomic + 3 composite (explore-area, plan-route, compare-places)
- **14 tools** — 11 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 | **13** | 3 |
| Tools | **14** | 3 |
| Geocoding | Yes | No |
| Step-by-step directions | Yes | No |
| Elevation | Yes | No |
Expand Down Expand Up @@ -58,6 +58,7 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup
| `maps_elevation` | Get elevation (meters above sea level) for geographic coordinates. |
| `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. |
| **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 @@ -111,7 +112,7 @@ Then configure your MCP client:
### Server Information

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

### CLI Exec Mode (Agent Skill)

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

All 13 tools available: `geocode`, `reverse-geocode`, `search-nearby`, `search-places`, `place-details`, `directions`, `distance-matrix`, `elevation`, `timezone`, `weather`, `explore-area`, `plan-route`, `compare-places`. See [`skills/google-maps/`](./skills/google-maps/) for the agent skill definition and full parameter docs.
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.

### API Key Configuration

Expand Down Expand Up @@ -215,6 +216,7 @@ src/
│ ├── elevation.ts # maps_elevation tool
│ ├── timezone.ts # maps_timezone tool
│ ├── weather.ts # maps_weather tool
│ ├── airQuality.ts # maps_air_quality tool
│ ├── exploreArea.ts # maps_explore_area (composite)
│ ├── planRoute.ts # maps_plan_route (composite)
│ └── comparePlaces.ts # maps_compare_places (composite)
Expand Down Expand Up @@ -256,7 +258,7 @@ 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_air_quality` | AQI, pollutants — health-aware travel, outdoor planning, real estate | Planned |
| `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 |
Expand Down
3 changes: 2 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

13 tools in four categories — pick by scenario:
14 tools in four categories — pick by scenario:

### Place Discovery
| Tool | When to use | Example |
Expand All @@ -58,6 +58,7 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get
| `elevation` | Query altitude | "Elevation profile along this hiking trail" |
| `timezone` | Need local time at a destination | "What time is it in Tokyo?" |
| `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?" |

### Composite (one-call shortcuts)
| Tool | When to use | Example |
Expand Down
44 changes: 44 additions & 0 deletions skills/google-maps/references/tools-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,44 @@ exec weather '{"latitude": 37.4220, "longitude": -122.0841, "type": "forecast_da

---

## air-quality

Get air quality index, pollutant concentrations, and health recommendations for a location.

```bash
exec air-quality '{"latitude": 35.6762, "longitude": 139.6503}'
exec air-quality '{"latitude": 35.6762, "longitude": 139.6503, "includePollutants": true}'
```

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| latitude | number | yes | Latitude |
| longitude | number | yes | Longitude |
| includeHealthRecommendations | boolean | no | Health advice per demographic group (default: true) |
| includePollutants | boolean | no | Individual pollutant concentrations (default: false) |

Response:
```json
{
"aqi": 76,
"category": "Good",
"dominantPollutant": "pm25",
"healthRecommendations": {
"generalPopulation": "...",
"elderly": "...",
"lungDiseasePopulation": "...",
"heartDiseasePopulation": "...",
"athletes": "...",
"pregnantWomen": "...",
"children": "..."
}
}
```

Chaining: `geocode` → `air-quality` when the user gives an address instead of coordinates.

---

## explore-area (composite)

Explore a neighborhood in one call. Internally chains geocode → search-nearby (per type) → place-details (top N).
Expand Down Expand Up @@ -270,6 +308,12 @@ search-nearby {"center":{"value":"25.033,121.564","isCoordinates":true},"keyword
distance-matrix {"origins":["Taipei Main Station","Banqiao Station"],"destinations":["Taoyuan Airport","Songshan Airport"],"mode":"driving"}
```

**Geocode → Air Quality** — Check air quality at a named location.
```
geocode {"address":"Tokyo"}
air-quality {"latitude":35.6762,"longitude":139.6503}
```

---

## Scenario Recipes
Expand Down
10 changes: 10 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@
"explore-area",
"plan-route",
"compare-places",
"air-quality",
] as const;

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

Check warning on line 95 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

Check warning on line 95 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 @@ -167,6 +168,15 @@
case "maps_compare_places":
return searcher.comparePlaces(params);

case "air-quality":
case "maps_air_quality":
return searcher.getAirQuality(
params.latitude,
params.longitude,
params.includeHealthRecommendations,
params.includePollutants
);

default:
throw new Error(`Unknown tool: ${toolName}. Available: ${EXEC_TOOLS.join(", ")}`);
}
Expand All @@ -190,7 +200,7 @@
const packageJsonPath = resolve(__dirname, "../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
packageVersion = packageJson.version;
} catch (e) {

Check warning on line 203 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 @@ -241,7 +251,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 254 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
8 changes: 8 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Weather, WeatherParams } from "./tools/maps/weather.js";
import { ExploreArea, ExploreAreaParams } from "./tools/maps/exploreArea.js";
import { PlanRoute, PlanRouteParams } from "./tools/maps/planRoute.js";
import { ComparePlaces, ComparePlacesParams } from "./tools/maps/comparePlaces.js";
import { AirQuality, AirQualityParams } from "./tools/maps/airQuality.js";

// All Google Maps tools are read-only API queries
const MAPS_TOOL_ANNOTATIONS = {
Expand Down Expand Up @@ -125,6 +126,13 @@ const serverConfigs: ServerInstanceConfig[] = [
annotations: MAPS_TOOL_ANNOTATIONS,
action: (params: ComparePlacesParams) => ComparePlaces.ACTION(params),
},
{
name: AirQuality.NAME,
description: AirQuality.DESCRIPTION,
schema: AirQuality.SCHEMA,
annotations: MAPS_TOOL_ANNOTATIONS,
action: (params: AirQualityParams) => AirQuality.ACTION(params),
},
],
},
];
Expand Down
28 changes: 28 additions & 0 deletions src/services/PlacesSearcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ interface WeatherResponse {
data?: any;
}

interface AirQualityResponse {
success: boolean;
error?: string;
data?: any;
}

interface ElevationResponse {
success: boolean;
error?: string;
Expand Down Expand Up @@ -316,6 +322,28 @@ export class PlacesSearcher {
}
}

async getAirQuality(
latitude: number,
longitude: number,
includeHealthRecommendations?: boolean,
includePollutants?: boolean
): Promise<AirQualityResponse> {
try {
const result = await this.mapsTools.getAirQuality(
latitude,
longitude,
includeHealthRecommendations,
includePollutants
);
return { success: true, data: result };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "An error occurred while getting air quality",
};
}
}

// --------------- Composite Tools ---------------

async exploreArea(params: { location: string; types?: string[]; radius?: number; topN?: number }): Promise<any> {
Expand Down
84 changes: 84 additions & 0 deletions src/services/toolclass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,90 @@ export class GoogleMapsTools {
}
}

async getAirQuality(
latitude: number,
longitude: number,
includeHealthRecommendations: boolean = true,
includePollutants: boolean = false
): Promise<any> {
try {
const url = `https://airquality.googleapis.com/v1/currentConditions:lookup?key=${this.apiKey}`;

const extraComputations: string[] = [];
if (includeHealthRecommendations) {
extraComputations.push("HEALTH_RECOMMENDATIONS");
}
if (includePollutants) {
extraComputations.push("POLLUTANT_CONCENTRATION");
}

const body: any = {
location: { latitude, longitude },
};
if (extraComputations.length > 0) {
body.extraComputations = extraComputations;
}

const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const msg = errorData?.error?.message || `HTTP ${response.status}`;
throw new Error(msg);
}

const data = await response.json();

// Extract the primary index
const indexes = data.indexes || [];
const primaryIndex = indexes[0];

const result: any = {
dateTime: data.dateTime,
regionCode: data.regionCode,
aqi: primaryIndex?.aqi,
category: primaryIndex?.category,
dominantPollutant: primaryIndex?.dominantPollutant,
color: primaryIndex?.color,
};

// Include all available indexes (universal + local)
if (indexes.length > 1) {
result.indexes = indexes.map((idx: any) => ({
code: idx.code,
displayName: idx.displayName,
aqi: idx.aqi,
category: idx.category,
dominantPollutant: idx.dominantPollutant,
}));
}

// Health recommendations
if (data.healthRecommendations) {
result.healthRecommendations = data.healthRecommendations;
}

// Pollutants
if (data.pollutants) {
result.pollutants = data.pollutants.map((p: any) => ({
code: p.code,
displayName: p.displayName,
concentration: p.concentration,
additionalInfo: p.additionalInfo,
}));
}

return result;
} catch (error: any) {
Logger.error("Error in getAirQuality:", error);
throw new Error(error.message || `Failed to get air quality for (${latitude}, ${longitude})`);
}
}

async getTimezone(
latitude: number,
longitude: number,
Expand Down
60 changes: 60 additions & 0 deletions src/tools/maps/airQuality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { z } from "zod";
import { PlacesSearcher } from "../../services/PlacesSearcher.js";
import { getCurrentApiKey } from "../../utils/requestContext.js";

const NAME = "maps_air_quality";
const DESCRIPTION =
"Get air quality for a location — AQI index, pollutant concentrations, and health recommendations by demographic group (elderly, children, athletes, pregnant women, etc.). Use when the user asks 'is the air safe', 'should I wear a mask', 'good for outdoor exercise', or is planning travel for someone with respiratory/heart conditions. Coverage: global including Japan (unlike weather). Returns both universal AQI and local index (EPA for US, AEROS for Japan, etc.).";

const SCHEMA = {
latitude: z.number().describe("Latitude coordinate"),
longitude: z.number().describe("Longitude coordinate"),
includeHealthRecommendations: z
.boolean()
.optional()
.describe("Include health advice per demographic group (default: true)"),
includePollutants: z
.boolean()
.optional()
.describe("Include individual pollutant concentrations — PM2.5, PM10, NO2, O3, CO, SO2 (default: false)"),
};

export type AirQualityParams = z.infer<z.ZodObject<typeof SCHEMA>>;

async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
try {
const apiKey = getCurrentApiKey();
const placesSearcher = new PlacesSearcher(apiKey);
const result = await placesSearcher.getAirQuality(
params.latitude,
params.longitude,
params.includeHealthRecommendations,
params.includePollutants
);

if (!result.success) {
return {
content: [{ type: "text", text: result.error || "Failed to get air quality data" }],
isError: true,
};
}

return {
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
isError: false,
};
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
return {
isError: true,
content: [{ type: "text", text: `Error getting air quality: ${errorMessage}` }],
};
}
}

export const AirQuality = {
NAME,
DESCRIPTION,
SCHEMA,
ACTION,
};
1 change: 1 addition & 0 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ async function testListTools(session: McpSession): Promise<void> {
"maps_search_places",
"maps_timezone",
"maps_weather",
"maps_air_quality",
];

for (const name of expectedTools) {
Expand Down
Loading