Skip to content

Commit 1c45c9b

Browse files
cablateclaude
andauthored
feat: add maps_timezone and maps_weather tools (#42)
* feat: add maps_timezone and maps_weather tools - maps_timezone: timezone ID, name, UTC/DST offsets, local time via google-maps-services-js timezone() - maps_weather: current conditions (temperature, humidity, wind, UV, precipitation) via Google Weather API (weather.googleapis.com) - Register both tools in config, exec CLI, and smoke tests - Update README: 8 → 10 tools, add to comparison table and tool list - 94 tests passed (weather test is non-blocking if API not enabled) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: enhance weather tool with forecast, friendly errors, coverage info - Add forecast_daily (up to 10 days) and forecast_hourly (up to 240 hours) - Add friendly error message for unsupported regions (Japan, China, etc.) - Document coverage limitations in tool description - Use US coordinates in tests (Japan unsupported by Weather API) - Fix pressure field mapping (airPressure, not pressure) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: improve tool descriptions with scenario-based triggers Apply agentskill-expertise P4 pattern — descriptions now lead with user trigger scenarios instead of feature lists: - elevation: "how high is this place", "is this area flood-prone" - timezone: "what time is it in Tokyo", "coordinate meeting across timezones" - weather: "what's the weather in Paris", "pack for a trip" + shorten Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: run prettier on changed files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add mcpregistry tokens and .mcp.json to gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fb04d3b commit 1c45c9b

10 files changed

Lines changed: 358 additions & 8 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ dist/
22
node_modules/
33
credentials.json
44
.env
5-
.agents/*
5+
.agents/*
6+
.mcpregistry_*
7+
.mcp.json

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@
66

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

9-
- **8 tools** — geocode, reverse-geocode, search-nearby, search-places, place-details, directions, distance-matrix, elevation
9+
- **10 tools** — geocode, reverse-geocode, search-nearby, search-places, place-details, directions, distance-matrix, elevation, timezone, weather
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 | **8** | 3 |
17+
| Tools | **10** | 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 |
23+
| Timezone | Yes | No |
24+
| Weather | Yes | Yes |
2325
| Open source | MIT | No |
2426
| Self-hosted | Yes | Google-managed only |
2527
| Agent Skill | Yes | No |
@@ -53,6 +55,8 @@ Special thanks to [@junyinnnn](https://github.com/junyinnnn) for helping add sup
5355
| `maps_distance_matrix` | Calculate travel distances and times between multiple origins and destinations. |
5456
| `maps_directions` | Get step-by-step navigation between two points with route details. |
5557
| `maps_elevation` | Get elevation (meters above sea level) for geographic coordinates. |
58+
| `maps_timezone` | Get timezone ID, name, UTC/DST offsets, and local time for coordinates. |
59+
| `maps_weather` | Get current weather conditions — temperature, humidity, wind, UV, precipitation. |
5660

5761
All tools are annotated with `readOnlyHint: true` and `destructiveHint: false` — MCP clients can auto-approve these without user confirmation.
5862

@@ -102,7 +106,7 @@ Then configure your MCP client:
102106
### Server Information
103107

104108
- **Transport**: stdio (`--stdio`) or Streamable HTTP (default)
105-
- **Tools**: 8 Google Maps tools
109+
- **Tools**: 10 Google Maps tools
106110

107111
### CLI Exec Mode (Agent Skill)
108112

@@ -113,7 +117,7 @@ npx @cablate/mcp-google-map exec geocode '{"address":"Tokyo Tower"}'
113117
npx @cablate/mcp-google-map exec search-places '{"query":"ramen in Tokyo"}'
114118
```
115119

116-
All 8 tools available: `geocode`, `reverse-geocode`, `search-nearby`, `search-places`, `place-details`, `directions`, `distance-matrix`, `elevation`. See [`skills/google-maps/`](./skills/google-maps/) for the agent skill definition and full parameter docs.
120+
All 10 tools available: `geocode`, `reverse-geocode`, `search-nearby`, `search-places`, `place-details`, `directions`, `distance-matrix`, `elevation`, `timezone`, `weather`. See [`skills/google-maps/`](./skills/google-maps/) for the agent skill definition and full parameter docs.
117121

118122
### API Key Configuration
119123

@@ -203,7 +207,9 @@ src/
203207
│ ├── reverseGeocode.ts # maps_reverse_geocode tool
204208
│ ├── distanceMatrix.ts # maps_distance_matrix tool
205209
│ ├── directions.ts # maps_directions tool
206-
│ └── elevation.ts # maps_elevation tool
210+
│ ├── elevation.ts # maps_elevation tool
211+
│ ├── timezone.ts # maps_timezone tool
212+
│ └── weather.ts # maps_weather tool
207213
└── utils/
208214
├── apiKeyManager.ts # API key management
209215
└── requestContext.ts # Per-request context (API key isolation)

src/cli.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ const EXEC_TOOLS = [
8484
"directions",
8585
"distance-matrix",
8686
"elevation",
87+
"timezone",
88+
"weather",
8789
] as const;
8890

8991
async function execTool(toolName: string, params: any, apiKey: string): Promise<any> {
@@ -134,6 +136,20 @@ async function execTool(toolName: string, params: any, apiKey: string): Promise<
134136
case "maps_elevation":
135137
return searcher.getElevation(params.locations);
136138

139+
case "timezone":
140+
case "maps_timezone":
141+
return searcher.getTimezone(params.latitude, params.longitude, params.timestamp);
142+
143+
case "weather":
144+
case "maps_weather":
145+
return searcher.getWeather(
146+
params.latitude,
147+
params.longitude,
148+
params.type,
149+
params.forecastDays,
150+
params.forecastHours
151+
);
152+
137153
default:
138154
throw new Error(`Unknown tool: ${toolName}. Available: ${EXEC_TOOLS.join(", ")}`);
139155
}

src/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { DistanceMatrix, DistanceMatrixParams } from "./tools/maps/distanceMatri
99
import { Directions, DirectionsParams } from "./tools/maps/directions.js";
1010
import { Elevation, ElevationParams } from "./tools/maps/elevation.js";
1111
import { SearchPlaces, SearchPlacesParams } from "./tools/maps/searchPlaces.js";
12+
import { Timezone, TimezoneParams } from "./tools/maps/timezone.js";
13+
import { Weather, WeatherParams } from "./tools/maps/weather.js";
1214

1315
// All Google Maps tools are read-only API queries
1416
const MAPS_TOOL_ANNOTATIONS = {
@@ -85,6 +87,20 @@ const serverConfigs: ServerInstanceConfig[] = [
8587
annotations: MAPS_TOOL_ANNOTATIONS,
8688
action: (params: SearchPlacesParams) => SearchPlaces.ACTION(params),
8789
},
90+
{
91+
name: Timezone.NAME,
92+
description: Timezone.DESCRIPTION,
93+
schema: Timezone.SCHEMA,
94+
annotations: MAPS_TOOL_ANNOTATIONS,
95+
action: (params: TimezoneParams) => Timezone.ACTION(params),
96+
},
97+
{
98+
name: Weather.NAME,
99+
description: Weather.DESCRIPTION,
100+
schema: Weather.SCHEMA,
101+
annotations: MAPS_TOOL_ANNOTATIONS,
102+
action: (params: WeatherParams) => Weather.ACTION(params),
103+
},
88104
],
89105
},
90106
];

src/services/PlacesSearcher.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ interface DirectionsResponse {
5656
};
5757
}
5858

59+
interface TimezoneResponse {
60+
success: boolean;
61+
error?: string;
62+
data?: {
63+
timeZoneId: string;
64+
timeZoneName: string;
65+
utcOffset: number;
66+
dstOffset: number;
67+
localTime: string;
68+
};
69+
}
70+
71+
interface WeatherResponse {
72+
success: boolean;
73+
error?: string;
74+
data?: any;
75+
}
76+
5977
interface ElevationResponse {
6078
success: boolean;
6179
error?: string;
@@ -268,6 +286,36 @@ export class PlacesSearcher {
268286
}
269287
}
270288

289+
async getTimezone(latitude: number, longitude: number, timestamp?: number): Promise<TimezoneResponse> {
290+
try {
291+
const result = await this.mapsTools.getTimezone(latitude, longitude, timestamp);
292+
return { success: true, data: result };
293+
} catch (error) {
294+
return {
295+
success: false,
296+
error: error instanceof Error ? error.message : "An error occurred while getting timezone",
297+
};
298+
}
299+
}
300+
301+
async getWeather(
302+
latitude: number,
303+
longitude: number,
304+
type: "current" | "forecast_daily" | "forecast_hourly" = "current",
305+
forecastDays?: number,
306+
forecastHours?: number
307+
): Promise<WeatherResponse> {
308+
try {
309+
const result = await this.mapsTools.getWeather(latitude, longitude, type, forecastDays, forecastHours);
310+
return { success: true, data: result };
311+
} catch (error) {
312+
return {
313+
success: false,
314+
error: error instanceof Error ? error.message : "An error occurred while getting weather",
315+
};
316+
}
317+
}
318+
271319
async getElevation(locations: Array<{ latitude: number; longitude: number }>): Promise<ElevationResponse> {
272320
try {
273321
const result = await this.mapsTools.getElevation(locations);

src/services/toolclass.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,113 @@ export class GoogleMapsTools {
322322
}
323323
}
324324

325+
async getWeather(
326+
latitude: number,
327+
longitude: number,
328+
type: "current" | "forecast_daily" | "forecast_hourly" = "current",
329+
forecastDays?: number,
330+
forecastHours?: number
331+
): Promise<any> {
332+
try {
333+
const baseParams = `key=${this.apiKey}&location.latitude=${latitude}&location.longitude=${longitude}`;
334+
let url: string;
335+
336+
switch (type) {
337+
case "forecast_daily": {
338+
const days = Math.min(Math.max(forecastDays || 5, 1), 10);
339+
url = `https://weather.googleapis.com/v1/forecast/days:lookup?${baseParams}&days=${days}`;
340+
break;
341+
}
342+
case "forecast_hourly": {
343+
const hours = Math.min(Math.max(forecastHours || 24, 1), 240);
344+
url = `https://weather.googleapis.com/v1/forecast/hours:lookup?${baseParams}&hours=${hours}`;
345+
break;
346+
}
347+
default:
348+
url = `https://weather.googleapis.com/v1/currentConditions:lookup?${baseParams}`;
349+
}
350+
351+
const response = await fetch(url);
352+
353+
if (!response.ok) {
354+
const errorData = await response.json().catch(() => ({}));
355+
const msg = errorData?.error?.message || `HTTP ${response.status}`;
356+
357+
if (msg.includes("not supported for this location")) {
358+
throw new Error(
359+
`Weather data is not available for this location (${latitude}, ${longitude}). ` +
360+
"The Google Weather API has limited coverage — China, Japan, South Korea, Cuba, Iran, North Korea, and Syria are unsupported. " +
361+
"Try a location in North America, Europe, or Oceania."
362+
);
363+
}
364+
365+
throw new Error(msg);
366+
}
367+
368+
const data = await response.json();
369+
370+
if (type === "current") {
371+
return {
372+
temperature: data.temperature,
373+
feelsLike: data.feelsLikeTemperature,
374+
humidity: data.relativeHumidity,
375+
wind: data.wind,
376+
conditions: data.weatherCondition?.description?.text || data.weatherCondition?.type,
377+
uvIndex: data.uvIndex,
378+
precipitation: data.precipitation,
379+
visibility: data.visibility,
380+
pressure: data.airPressure,
381+
cloudCover: data.cloudCover,
382+
isDayTime: data.isDaytime,
383+
};
384+
}
385+
386+
// forecast_daily or forecast_hourly — return as-is with light cleanup
387+
return data;
388+
} catch (error: any) {
389+
Logger.error("Error in getWeather:", error);
390+
throw new Error(error.message || `Failed to get weather for (${latitude}, ${longitude})`);
391+
}
392+
}
393+
394+
async getTimezone(
395+
latitude: number,
396+
longitude: number,
397+
timestamp?: number
398+
): Promise<{ timeZoneId: string; timeZoneName: string; utcOffset: number; dstOffset: number; localTime: string }> {
399+
try {
400+
const ts = timestamp ? Math.floor(timestamp / 1000) : Math.floor(Date.now() / 1000);
401+
402+
const response = await this.client.timezone({
403+
params: {
404+
location: { lat: latitude, lng: longitude },
405+
timestamp: ts,
406+
key: this.apiKey,
407+
},
408+
});
409+
410+
const result = response.data;
411+
412+
if (result.status !== "OK") {
413+
throw new Error(`Timezone API returned status: ${result.status}`);
414+
}
415+
416+
const totalOffset = (result.rawOffset + result.dstOffset) * 1000;
417+
const localTime = new Date(ts * 1000 + totalOffset).toISOString().replace("Z", "");
418+
419+
return {
420+
timeZoneId: result.timeZoneId,
421+
timeZoneName: result.timeZoneName,
422+
utcOffset: result.rawOffset,
423+
dstOffset: result.dstOffset,
424+
localTime,
425+
};
426+
} catch (error: any) {
427+
Logger.error("Error in getTimezone:", error);
428+
throw new Error(`Failed to get timezone for (${latitude}, ${longitude}): ${extractErrorMessage(error)}`);
429+
}
430+
}
431+
325432
async getElevation(
326433
locations: Array<{ latitude: number; longitude: number }>
327434
): Promise<Array<{ elevation: number; location: { lat: number; lng: number } }>> {

src/tools/maps/elevation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getCurrentApiKey } from "../../utils/requestContext.js";
44

55
const NAME = "maps_elevation";
66
const DESCRIPTION =
7-
"Get elevation (height above sea level in meters) for one or more geographic coordinates. Use for terrain analysis, hiking/cycling route planning, or when the user asks about altitude at specific locations.";
7+
"Get elevation (meters above sea level) for geographic coordinates. Use when the user asks 'how high is this place', 'is this area flood-prone', or needs altitude for hiking/cycling route profiles. Also useful for real estate risk assessment — low elevation near water suggests flood risk.";
88

99
const SCHEMA = {
1010
locations: z

src/tools/maps/timezone.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { z } from "zod";
2+
import { PlacesSearcher } from "../../services/PlacesSearcher.js";
3+
import { getCurrentApiKey } from "../../utils/requestContext.js";
4+
5+
const NAME = "maps_timezone";
6+
const DESCRIPTION =
7+
"Get the timezone and current local time for a location. Use when the user asks 'what time is it in Tokyo', needs to coordinate a meeting across timezones, or is planning travel across timezone boundaries. Returns timezone ID, UTC/DST offsets, and computed local time.";
8+
9+
const SCHEMA = {
10+
latitude: z.number().describe("Latitude coordinate"),
11+
longitude: z.number().describe("Longitude coordinate"),
12+
timestamp: z
13+
.number()
14+
.optional()
15+
.describe("Unix timestamp in ms to query timezone at a specific moment (defaults to now)"),
16+
};
17+
18+
export type TimezoneParams = z.infer<z.ZodObject<typeof SCHEMA>>;
19+
20+
async function ACTION(params: any): Promise<{ content: any[]; isError?: boolean }> {
21+
try {
22+
const apiKey = getCurrentApiKey();
23+
const placesSearcher = new PlacesSearcher(apiKey);
24+
const result = await placesSearcher.getTimezone(params.latitude, params.longitude, params.timestamp);
25+
26+
if (!result.success) {
27+
return {
28+
content: [{ type: "text", text: result.error || "Failed to get timezone data" }],
29+
isError: true,
30+
};
31+
}
32+
33+
return {
34+
content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
35+
isError: false,
36+
};
37+
} catch (error: any) {
38+
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
39+
return {
40+
isError: true,
41+
content: [{ type: "text", text: `Error getting timezone: ${errorMessage}` }],
42+
};
43+
}
44+
}
45+
46+
export const Timezone = {
47+
NAME,
48+
DESCRIPTION,
49+
SCHEMA,
50+
ACTION,
51+
};

0 commit comments

Comments
 (0)