From 3443058083ae1e2c6a31bbcecd19d1f9e4bc5489 Mon Sep 17 00:00:00 2001 From: CabLate Date: Mon, 16 Mar 2026 19:26:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20maps=5Fair=5Fquality=20tool=20?= =?UTF-8?q?=E2=80=94=20AQI,=20pollutants,=20health=20recommendations=20(#P?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 12 ++-- skills/google-maps/SKILL.md | 3 +- skills/google-maps/references/tools-api.md | 44 ++++++++++++ src/cli.ts | 10 +++ src/config.ts | 8 +++ src/services/PlacesSearcher.ts | 28 ++++++++ src/services/toolclass.ts | 84 ++++++++++++++++++++++ src/tools/maps/airQuality.ts | 60 ++++++++++++++++ tests/smoke.test.ts | 1 + 9 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 src/tools/maps/airQuality.ts diff --git a/README.md b/README.md index ae110ee..75483e2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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/)) @@ -14,7 +14,7 @@ Give your AI agent the ability to understand the physical world — geocode, rou | | 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 | @@ -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. | @@ -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) @@ -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 @@ -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) @@ -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 | diff --git a/skills/google-maps/SKILL.md b/skills/google-maps/SKILL.md index 6869ec4..8aede3a 100644 --- a/skills/google-maps/SKILL.md +++ b/skills/google-maps/SKILL.md @@ -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 | @@ -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 | diff --git a/skills/google-maps/references/tools-api.md b/skills/google-maps/references/tools-api.md index 4db190a..4a56c4b 100644 --- a/skills/google-maps/references/tools-api.md +++ b/skills/google-maps/references/tools-api.md @@ -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). @@ -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 diff --git a/src/cli.ts b/src/cli.ts index 20265cc..292563e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -89,6 +89,7 @@ const EXEC_TOOLS = [ "explore-area", "plan-route", "compare-places", + "air-quality", ] as const; async function execTool(toolName: string, params: any, apiKey: string): Promise { @@ -167,6 +168,15 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise< 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(", ")}`); } diff --git a/src/config.ts b/src/config.ts index 2d64375..2c5b439 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 = { @@ -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), + }, ], }, ]; diff --git a/src/services/PlacesSearcher.ts b/src/services/PlacesSearcher.ts index 8d31d93..e97a060 100644 --- a/src/services/PlacesSearcher.ts +++ b/src/services/PlacesSearcher.ts @@ -74,6 +74,12 @@ interface WeatherResponse { data?: any; } +interface AirQualityResponse { + success: boolean; + error?: string; + data?: any; +} + interface ElevationResponse { success: boolean; error?: string; @@ -316,6 +322,28 @@ export class PlacesSearcher { } } + async getAirQuality( + latitude: number, + longitude: number, + includeHealthRecommendations?: boolean, + includePollutants?: boolean + ): Promise { + 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 { diff --git a/src/services/toolclass.ts b/src/services/toolclass.ts index 2bf08ac..26e8f19 100644 --- a/src/services/toolclass.ts +++ b/src/services/toolclass.ts @@ -391,6 +391,90 @@ export class GoogleMapsTools { } } + async getAirQuality( + latitude: number, + longitude: number, + includeHealthRecommendations: boolean = true, + includePollutants: boolean = false + ): Promise { + 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, diff --git a/src/tools/maps/airQuality.ts b/src/tools/maps/airQuality.ts new file mode 100644 index 0000000..1f9ac78 --- /dev/null +++ b/src/tools/maps/airQuality.ts @@ -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>; + +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, +}; diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index e40d642..84e32a2 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -210,6 +210,7 @@ async function testListTools(session: McpSession): Promise { "maps_search_places", "maps_timezone", "maps_weather", + "maps_air_quality", ]; for (const name of expectedTools) {