Skip to content

Commit c5f0ca3

Browse files
cablateclaude
andauthored
feat: add maps_air_quality tool — AQI, pollutants, health recommendations (#P0) (#48)
- New tool: maps_air_quality with universal AQI + local index (EPA, AEROS, etc.) - 7 demographic health recommendations (elderly, children, athletes, pregnant, etc.) - Optional pollutant concentrations (PM2.5, PM10, NO2, O3, CO, SO2) - Global coverage including Japan (unlike weather API) - All 9 files synced per Tool Change Checklist (13 → 14 tools) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0cc4089 commit c5f0ca3

9 files changed

Lines changed: 244 additions & 6 deletions

File tree

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66

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

9-
- **13 tools**10 atomic + 3 composite (explore-area, plan-route, compare-places)
9+
- **14 tools**11 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 | **13** | 3 |
17+
| Tools | **14** | 3 |
1818
| Geocoding | Yes | No |
1919
| Step-by-step directions | Yes | No |
2020
| Elevation | Yes | No |
@@ -58,6 +58,7 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup
5858
| `maps_elevation` | Get elevation (meters above sea level) for geographic coordinates. |
5959
| `maps_timezone` | Get timezone ID, name, UTC/DST offsets, and local time for coordinates. |
6060
| `maps_weather` | Get current weather conditions or forecast — temperature, humidity, wind, UV, precipitation. |
61+
| `maps_air_quality` | Get air quality index, pollutant concentrations, and health recommendations by demographic group. |
6162
| **Composite Tools** | |
6263
| `maps_explore_area` | Explore what's around a location — searches multiple place types and gets details in one call. |
6364
| `maps_plan_route` | Plan an optimized multi-stop route — geocodes, finds best order, returns directions. |
@@ -111,7 +112,7 @@ Then configure your MCP client:
111112
### Server Information
112113

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

116117
### CLI Exec Mode (Agent Skill)
117118

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

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

127128
### API Key Configuration
128129

@@ -215,6 +216,7 @@ src/
215216
│ ├── elevation.ts # maps_elevation tool
216217
│ ├── timezone.ts # maps_timezone tool
217218
│ ├── weather.ts # maps_weather tool
219+
│ ├── airQuality.ts # maps_air_quality tool
218220
│ ├── exploreArea.ts # maps_explore_area (composite)
219221
│ ├── planRoute.ts # maps_plan_route (composite)
220222
│ └── comparePlaces.ts # maps_compare_places (composite)
@@ -256,7 +258,7 @@ For enterprise security reviews, see [Security Assessment Clarifications](./SECU
256258
| Tool | What it unlocks | Status |
257259
|------|----------------|--------|
258260
| `maps_static_map` | Return map images with pins/routes — multimodal AI can "see" the map | Planned |
259-
| `maps_air_quality` | AQI, pollutants — health-aware travel, outdoor planning, real estate | Planned |
261+
| `maps_air_quality` | AQI, pollutants — health-aware travel, outdoor planning, real estate | **Done** |
260262
| `maps_validate_address` | Standardize and verify addresses — logistics/e-commerce | Planned |
261263
| `maps_isochrone` | "Show me everything within 30 min drive" — reachability analysis | Planned |
262264
| `maps_batch_geocode` | Geocode hundreds of addresses in one call — data enrichment | Planned |

skills/google-maps/SKILL.md

Lines changed: 2 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-
13 tools in four categories — pick by scenario:
38+
14 tools in four categories — pick by scenario:
3939

4040
### Place Discovery
4141
| Tool | When to use | Example |
@@ -58,6 +58,7 @@ Without this Skill, the agent can only guess or refuse when asked "how do I get
5858
| `elevation` | Query altitude | "Elevation profile along this hiking trail" |
5959
| `timezone` | Need local time at a destination | "What time is it in Tokyo?" |
6060
| `weather` | Weather at a location (current or forecast) | "What's the weather in Paris?" |
61+
| `air-quality` | AQI, pollutants, health recommendations | "Is the air safe for jogging?" |
6162

6263
### Composite (one-call shortcuts)
6364
| Tool | When to use | Example |

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,44 @@ exec weather '{"latitude": 37.4220, "longitude": -122.0841, "type": "forecast_da
200200

201201
---
202202

203+
## air-quality
204+
205+
Get air quality index, pollutant concentrations, and health recommendations for a location.
206+
207+
```bash
208+
exec air-quality '{"latitude": 35.6762, "longitude": 139.6503}'
209+
exec air-quality '{"latitude": 35.6762, "longitude": 139.6503, "includePollutants": true}'
210+
```
211+
212+
| Param | Type | Required | Description |
213+
|-------|------|----------|-------------|
214+
| latitude | number | yes | Latitude |
215+
| longitude | number | yes | Longitude |
216+
| includeHealthRecommendations | boolean | no | Health advice per demographic group (default: true) |
217+
| includePollutants | boolean | no | Individual pollutant concentrations (default: false) |
218+
219+
Response:
220+
```json
221+
{
222+
"aqi": 76,
223+
"category": "Good",
224+
"dominantPollutant": "pm25",
225+
"healthRecommendations": {
226+
"generalPopulation": "...",
227+
"elderly": "...",
228+
"lungDiseasePopulation": "...",
229+
"heartDiseasePopulation": "...",
230+
"athletes": "...",
231+
"pregnantWomen": "...",
232+
"children": "..."
233+
}
234+
}
235+
```
236+
237+
Chaining: `geocode``air-quality` when the user gives an address instead of coordinates.
238+
239+
---
240+
203241
## explore-area (composite)
204242

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

311+
**Geocode → Air Quality** — Check air quality at a named location.
312+
```
313+
geocode {"address":"Tokyo"}
314+
air-quality {"latitude":35.6762,"longitude":139.6503}
315+
```
316+
273317
---
274318

275319
## Scenario Recipes

src/cli.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const EXEC_TOOLS = [
8989
"explore-area",
9090
"plan-route",
9191
"compare-places",
92+
"air-quality",
9293
] as const;
9394

9495
async function execTool(toolName: string, params: any, apiKey: string): Promise<any> {
@@ -167,6 +168,15 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise<
167168
case "maps_compare_places":
168169
return searcher.comparePlaces(params);
169170

171+
case "air-quality":
172+
case "maps_air_quality":
173+
return searcher.getAirQuality(
174+
params.latitude,
175+
params.longitude,
176+
params.includeHealthRecommendations,
177+
params.includePollutants
178+
);
179+
170180
default:
171181
throw new Error(`Unknown tool: ${toolName}. Available: ${EXEC_TOOLS.join(", ")}`);
172182
}

src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Weather, WeatherParams } from "./tools/maps/weather.js";
1414
import { ExploreArea, ExploreAreaParams } from "./tools/maps/exploreArea.js";
1515
import { PlanRoute, PlanRouteParams } from "./tools/maps/planRoute.js";
1616
import { ComparePlaces, ComparePlacesParams } from "./tools/maps/comparePlaces.js";
17+
import { AirQuality, AirQualityParams } from "./tools/maps/airQuality.js";
1718

1819
// All Google Maps tools are read-only API queries
1920
const MAPS_TOOL_ANNOTATIONS = {
@@ -125,6 +126,13 @@ const serverConfigs: ServerInstanceConfig[] = [
125126
annotations: MAPS_TOOL_ANNOTATIONS,
126127
action: (params: ComparePlacesParams) => ComparePlaces.ACTION(params),
127128
},
129+
{
130+
name: AirQuality.NAME,
131+
description: AirQuality.DESCRIPTION,
132+
schema: AirQuality.SCHEMA,
133+
annotations: MAPS_TOOL_ANNOTATIONS,
134+
action: (params: AirQualityParams) => AirQuality.ACTION(params),
135+
},
128136
],
129137
},
130138
];

src/services/PlacesSearcher.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ interface WeatherResponse {
7474
data?: any;
7575
}
7676

77+
interface AirQualityResponse {
78+
success: boolean;
79+
error?: string;
80+
data?: any;
81+
}
82+
7783
interface ElevationResponse {
7884
success: boolean;
7985
error?: string;
@@ -316,6 +322,28 @@ export class PlacesSearcher {
316322
}
317323
}
318324

325+
async getAirQuality(
326+
latitude: number,
327+
longitude: number,
328+
includeHealthRecommendations?: boolean,
329+
includePollutants?: boolean
330+
): Promise<AirQualityResponse> {
331+
try {
332+
const result = await this.mapsTools.getAirQuality(
333+
latitude,
334+
longitude,
335+
includeHealthRecommendations,
336+
includePollutants
337+
);
338+
return { success: true, data: result };
339+
} catch (error) {
340+
return {
341+
success: false,
342+
error: error instanceof Error ? error.message : "An error occurred while getting air quality",
343+
};
344+
}
345+
}
346+
319347
// --------------- Composite Tools ---------------
320348

321349
async exploreArea(params: { location: string; types?: string[]; radius?: number; topN?: number }): Promise<any> {

src/services/toolclass.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,90 @@ export class GoogleMapsTools {
391391
}
392392
}
393393

394+
async getAirQuality(
395+
latitude: number,
396+
longitude: number,
397+
includeHealthRecommendations: boolean = true,
398+
includePollutants: boolean = false
399+
): Promise<any> {
400+
try {
401+
const url = `https://airquality.googleapis.com/v1/currentConditions:lookup?key=${this.apiKey}`;
402+
403+
const extraComputations: string[] = [];
404+
if (includeHealthRecommendations) {
405+
extraComputations.push("HEALTH_RECOMMENDATIONS");
406+
}
407+
if (includePollutants) {
408+
extraComputations.push("POLLUTANT_CONCENTRATION");
409+
}
410+
411+
const body: any = {
412+
location: { latitude, longitude },
413+
};
414+
if (extraComputations.length > 0) {
415+
body.extraComputations = extraComputations;
416+
}
417+
418+
const response = await fetch(url, {
419+
method: "POST",
420+
headers: { "Content-Type": "application/json" },
421+
body: JSON.stringify(body),
422+
});
423+
424+
if (!response.ok) {
425+
const errorData = await response.json().catch(() => ({}));
426+
const msg = errorData?.error?.message || `HTTP ${response.status}`;
427+
throw new Error(msg);
428+
}
429+
430+
const data = await response.json();
431+
432+
// Extract the primary index
433+
const indexes = data.indexes || [];
434+
const primaryIndex = indexes[0];
435+
436+
const result: any = {
437+
dateTime: data.dateTime,
438+
regionCode: data.regionCode,
439+
aqi: primaryIndex?.aqi,
440+
category: primaryIndex?.category,
441+
dominantPollutant: primaryIndex?.dominantPollutant,
442+
color: primaryIndex?.color,
443+
};
444+
445+
// Include all available indexes (universal + local)
446+
if (indexes.length > 1) {
447+
result.indexes = indexes.map((idx: any) => ({
448+
code: idx.code,
449+
displayName: idx.displayName,
450+
aqi: idx.aqi,
451+
category: idx.category,
452+
dominantPollutant: idx.dominantPollutant,
453+
}));
454+
}
455+
456+
// Health recommendations
457+
if (data.healthRecommendations) {
458+
result.healthRecommendations = data.healthRecommendations;
459+
}
460+
461+
// Pollutants
462+
if (data.pollutants) {
463+
result.pollutants = data.pollutants.map((p: any) => ({
464+
code: p.code,
465+
displayName: p.displayName,
466+
concentration: p.concentration,
467+
additionalInfo: p.additionalInfo,
468+
}));
469+
}
470+
471+
return result;
472+
} catch (error: any) {
473+
Logger.error("Error in getAirQuality:", error);
474+
throw new Error(error.message || `Failed to get air quality for (${latitude}, ${longitude})`);
475+
}
476+
}
477+
394478
async getTimezone(
395479
latitude: number,
396480
longitude: number,

src/tools/maps/airQuality.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { z } from "zod";
2+
import { PlacesSearcher } from "../../services/PlacesSearcher.js";
3+
import { getCurrentApiKey } from "../../utils/requestContext.js";
4+
5+
const NAME = "maps_air_quality";
6+
const DESCRIPTION =
7+
"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.).";
8+
9+
const SCHEMA = {
10+
latitude: z.number().describe("Latitude coordinate"),
11+
longitude: z.number().describe("Longitude coordinate"),
12+
includeHealthRecommendations: z
13+
.boolean()
14+
.optional()
15+
.describe("Include health advice per demographic group (default: true)"),
16+
includePollutants: z
17+
.boolean()
18+
.optional()
19+
.describe("Include individual pollutant concentrations — PM2.5, PM10, NO2, O3, CO, SO2 (default: false)"),
20+
};
21+
22+
export type AirQualityParams = z.infer<z.ZodObject<typeof SCHEMA>>;
23+
24+
async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
25+
try {
26+
const apiKey = getCurrentApiKey();
27+
const placesSearcher = new PlacesSearcher(apiKey);
28+
const result = await placesSearcher.getAirQuality(
29+
params.latitude,
30+
params.longitude,
31+
params.includeHealthRecommendations,
32+
params.includePollutants
33+
);
34+
35+
if (!result.success) {
36+
return {
37+
content: [{ type: "text", text: result.error || "Failed to get air quality data" }],
38+
isError: true,
39+
};
40+
}
41+
42+
return {
43+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
44+
isError: false,
45+
};
46+
} catch (error: any) {
47+
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
48+
return {
49+
isError: true,
50+
content: [{ type: "text", text: `Error getting air quality: ${errorMessage}` }],
51+
};
52+
}
53+
}
54+
55+
export const AirQuality = {
56+
NAME,
57+
DESCRIPTION,
58+
SCHEMA,
59+
ACTION,
60+
};

tests/smoke.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ async function testListTools(session: McpSession): Promise<void> {
210210
"maps_search_places",
211211
"maps_timezone",
212212
"maps_weather",
213+
"maps_air_quality",
213214
];
214215

215216
for (const name of expectedTools) {

0 commit comments

Comments
 (0)