How to Build a Travel Planning Agent with Geoapify MCP Tools
- API or service
- Geoapify MCP Server
- Task
- Travel request → MCP tool calls → walking day plan
- Examples
- Difficulty
- Intermediate
- Time
- 15 min
Turn a travel request into a walking day plan
A traveler asks an AI agent:
Plan a walking day in San Francisco. Start and end at the Ferry Building.
Morning: a museum. Then a viewpoint. After that, Italian lunch in North Beach.
Keep each walk under 25 minutes. Use the Geoapify tools for places and walking times.
A language model can name famous places from memory, but it cannot reliably tell which museum is open in the morning, which lunch spot is a short walk from the viewpoint, or how long each walk takes. The Geoapify MCP Server gives the agent tools for these facts. The agent chains five of them:
geocode_addressturns "Ferry Building, San Francisco" into coordinates.list_place_categoriesmaps "museum", "viewpoint", and "Italian lunch" to supported category IDs.search_placesfinds real candidates for each stop.calculate_route_matrixcompares walking times from one stop to several candidates.calculate_routereturns the walking distance and time of every leg of the final day.
Task flow: travel request → geocode_address → list_place_categories → search_places per stop → calculate_route_matrix to choose among candidates → calculate_route for the full day → day plan built only from tool results.
This guide shows the real tool calls and results of that workflow, then the agent instructions that keep it grounded. It assumes the Geoapify MCP Server is already connected to your agent. To set it up, see Connect Geoapify MCP to an AI client or Add Geoapify MCP tools to OpenClaw and other agents.
Map the travel request to MCP tools
Split the request into facts the agent must look up and decisions it can make itself:
| Part of the request | Tool | Key arguments | Result used next |
|---|---|---|---|
| "Start and end at the Ferry Building" | geocode_address |
query, limit: 1 |
lat, lon of the start |
| "museum", "viewpoint", "Italian lunch" | list_place_categories |
none | entertainment.museum, tourism.attraction.viewpoint, catering.restaurant.italian |
| One candidate list per stop | search_places |
category, lat, lon, radius_meters, limit |
Names, coordinates, opening_hours, distance |
| "Keep each walk under 25 minutes" while choosing a stop | calculate_route_matrix |
sources, targets, mode: "walk" |
Walking time and distance per pair |
| The final day | calculate_route |
waypoints, mode: "walk", geometry_scope |
Total and per-leg walking time |
Search for each stop around the right center. The museum and viewpoint searches use the Ferry Building because the day starts there. The lunch search uses the chosen viewpoint, because lunch follows it.
The distance field in search_places results is a straight-line distance from the search center. Use calculate_route_matrix or calculate_route for walking time. On hilly or irregular street grids, the walking time can be much longer than the straight-line distance suggests.
Choose the tool arguments to keep the context small. Five candidates per stop are enough for a day plan, and geometry_scope: "none" skips route geometry when the agent only needs times. Request geometry only when the application draws the route on a map.
Credits for this workflow: geocode_address 1, list_place_categories 0, three search_places calls with up to 20 results each 3, a 1 × 5 walking matrix 5, and a route with four legs 4, about 13 credits in total. tools/list and initialize are free. See MCP pricing.
Find the start and candidate stops
Geocode the start
{
"name": "geocode_address",
"arguments": {
"query": "Ferry Building, San Francisco, CA",
"limit": 1,
"lang": "en"
}
}
Reduced result:
{
"count": 1,
"results": [
{
"formatted": "Ferry Building, Harry Bridges Plaza, San Francisco, CA 94111, United States of America",
"lat": 37.7947815,
"lon": -122.393678,
"result_type": "amenity",
"rank": {"confidence": 1, "match_type": "full_match"}
}
]
}
Continue only with one confident result. If the result is empty or ambiguous, ask the traveler to clarify.
Choose category IDs
list_place_categories takes an empty argument object and returns 833 supported category IDs. The agent selects entertainment.museum, tourism.attraction.viewpoint, and catering.restaurant.italian. The list rarely changes, so an agent can reuse it during a session.
Find a morning museum
{
"name": "search_places",
"arguments": {
"category": "entertainment.museum",
"lat": 37.7947815,
"lon": -122.393678,
"radius_meters": 2000,
"limit": 5,
"lang": "en"
}
}
Reduced result:
| Name | Distance | opening_hours |
|---|---|---|
| San Francisco Railway Museum | 109 m | Tu-Sa 12:00-17:00 |
| Pier 24 | 787 m | not returned |
| Exploratorium | 802 m | Tu-Su 10:00-17:00; Jun 19 - Sep 4 Mo-Su 10:00-17:00, Fr 10:00-21:00 |
| Institute of Contemporary Art San Francisco | 860 m | We, Fr-Su 11:00-17:00; Th 11:00-19:00 |
| San Francisco Historical Society Museum | 877 m | Tu-Sa 10:00-16:00 |
The nearest museum opens at noon, which does not fit "morning". The agent picks the Exploratorium (lat 37.8008982, lon -122.3985255), which opens at 10:00 from Tuesday to Sunday. Opening hours come from OpenStreetMap and can be missing or out of date, so the plan should tell the traveler to confirm them.
Find the viewpoint
The same search with "category": "tourism.attraction.viewpoint" returns the Bay Bridge and Treasure Island Viewpoint (346 m), Bay Lights Viewpoint (836 m), two unnamed viewpoints, and Coit Tower (1,362 m). Coit Tower is the only named result in the North Beach area ("suburb": "North Beach"), where the traveler wants lunch, so the agent picks it (lat 37.8023791, lon -122.4058344). Skip unnamed results in a plan that people will read.
Find lunch near the viewpoint
{
"name": "search_places",
"arguments": {
"category": "catering.restaurant.italian",
"lat": 37.8023791,
"lon": -122.4058344,
"radius_meters": 800,
"limit": 5,
"lang": "en"
}
}
Reduced result:
| Name | Address | Distance | opening_hours |
|---|---|---|---|
| Bocce Cafe | 478 Green Street | 297 m | not returned |
| Sodini's | 510 Green Street | 331 m | Mo-Fr 17:00-00:00; Sa-Su16:00-00:00 |
| Caffé Sport | 574 Green Street | 388 m | not returned |
| Flour + Water Pizzeria | 532 Columbus Avenue | 419 m | Mo-Su 11:30-22:00 |
| Mona Lisa Mare e Monti | 414 Columbus Avenue | 427 m | not returned |
Sodini's opens in the evening, so it is not a lunch option. The next step compares the walking times of the candidates.
Compare walks and build the day route
Compare lunch candidates by walking time
Use calculate_route_matrix with the viewpoint as the only source and the five lunch candidates as targets:
{
"name": "calculate_route_matrix",
"arguments": {
"sources": [{"lat": 37.8023791, "lon": -122.4058344}],
"targets": [
{"lat": 37.7998993, "lon": -122.4070903},
{"lat": 37.7997834, "lon": -122.4076847},
{"lat": 37.7996767, "lon": -122.4086205},
{"lat": 37.8000523, "lon": -122.4095795},
{"lat": 37.7989554, "lon": -122.4080374}
],
"mode": "walk"
}
}
Reduced result:
{
"sources_to_targets": [[
{"distance": 449, "time": 426, "source_index": 0, "target_index": 0},
{"distance": 542, "time": 473, "source_index": 0, "target_index": 1},
{"distance": 630, "time": 548, "source_index": 0, "target_index": 2},
{"distance": 680, "time": 598, "source_index": 0, "target_index": 3},
{"distance": 708, "time": 611, "source_index": 0, "target_index": 4}
]],
"distance_units": "meters",
"time_units": "seconds"
}
Match cells to places by target_index, not by name. Bocce Cafe (index 0) is the shortest walk, 449 m and about 7 minutes. Its opening hours were not returned, so the agent offers it with a note and names the closest place with confirmed lunch hours, Flour + Water Pizzeria (index 3, about 10 minutes, opens 11:30), as the alternative.
The matrix also shows why the agent needs walking times. Bocce Cafe is 297 m from Coit Tower in a straight line but 449 m on foot.
Calculate the full walking day
Send the stops in the order the traveler asked for, and return to the start:
{
"name": "calculate_route",
"arguments": {
"waypoints": [
{"lat": 37.7947815, "lon": -122.393678},
{"lat": 37.8008982, "lon": -122.3985255},
{"lat": 37.8023791, "lon": -122.4058344},
{"lat": 37.7998993, "lon": -122.4070903},
{"lat": 37.7947815, "lon": -122.393678}
],
"mode": "walk",
"geometry_scope": "none"
}
}
Result:
{
"distance": 4037,
"units": "metric",
"distance_units": "meters",
"time": 3870.437,
"time_units": "seconds",
"legs": [
{"distance": 843, "time": 744.579},
{"distance": 1113, "time": 1292.075},
{"distance": 449, "time": 426.141},
{"distance": 1631, "time": 1407.64}
]
}
Every leg is under 25 minutes, so the plan meets the traveler's limit. The day has 4.0 km and about 65 minutes of walking. The second leg covers 1,113 m in about 22 minutes, slower per meter than the others. Always use the returned time rather than a fixed walking speed.
The traveler fixed the order of the stops, so this call keeps optimize_stops at its default, false. When the order is flexible, set "optimize_stops": true to let the tool reorder the middle stops while it keeps the first and last waypoint. For this day, the optimized call returned the same order and the same 4,037 m.
Present the day plan
The agent adds visit lengths of its own, labels them as assumptions, and converts leg times into a schedule:
| Time | Stop | Walk to the next stop |
|---|---|---|
| 10:00 | Start at the Ferry Building | 843 m, 12 min |
| 10:12–12:12 | Exploratorium (opens 10:00, Tue–Sun; 2 hours assumed) | 1,113 m, 22 min |
| 12:34–13:04 | Coit Tower viewpoint (30 minutes assumed) | 449 m, 7 min |
| 13:11–14:11 | Lunch at Bocce Cafe (hours not listed, confirm; alternative: Flour + Water Pizzeria) | 1,631 m, 23 min |
| 14:35 | Back at the Ferry Building | — |
Every place, distance, and walking time in the plan comes from a tool result. Visit lengths and the 10:00 start are the agent's assumptions and are labeled as such.
Add travel planning instructions to the agent
Add these rules to the agent's system prompt or instructions so it chains the tools the same way for any city:
You plan walking days with Geoapify MCP tools. Use tools for every place,
coordinate, distance, and travel time. Never add places from memory.
1. Geocode the start (and end, if different) with geocode_address, limit 1.
If the result is empty or ambiguous, ask the traveler to clarify.
2. Map each requested stop type to a category from list_place_categories.
Reuse the category list during the session.
3. For each stop, call search_places with limit 5 and a radius that fits the
request. Center the search on the previous chosen stop, or on the start
for the first stop.
4. Use opening_hours when the traveler gives a time of day. Exclude places
that are closed at that time. When opening_hours is missing, you may still
suggest the place, but say that its hours were not listed.
5. Skip results without a name.
6. When several candidates remain, call calculate_route_matrix with
mode "walk" from the previous stop to the candidates, and choose by
walking time. Match matrix cells by target_index.
7. Call calculate_route with mode "walk" and geometry_scope "none" for the
final order. Keep the traveler's order; set optimize_stops to true only
when the traveler allows any order.
8. Check every leg against the traveler's walking limit. If a leg is too
long, choose another candidate and recalculate.
9. Present a schedule with place names, addresses, leg distances, and leg
times from the tools. Label visit lengths and start times you assumed.
Tell the traveler that opening hours come from OpenStreetMap and should
be confirmed.
10. Keep lat and lon as named fields. Never swap them.
11. Stop after an authorization, quota, or repeated tool error and explain
what failed.
Each MCP tool validates its input schema and rejects unknown arguments. Read the schemas from tools/list before the first call. See the Geoapify MCP tools reference for every argument and limit.
Models choose differently from run to run, so test the agent with several requests and cities. Check that it calls the tools instead of answering from memory, that it searches around the right center for each stop, and that the final answer contains only places returned by search_places.