Skip to content

Commit 653981d

Browse files
committed
feat(map-server): multi-point sampling for visible places
- Sample multiple points in visible extent to discover places - Adaptive sampling based on extent size: - < 30km: center only (city zoom) - 30-100km: center + 2 corners - > 100km: center + 4 quadrants - Proper rate limiting for Nominatim (1.1s between requests) - Shows 'Visible places: City1, City2, ...' in model context - Fix: move geocode tool registration inside createServer()
1 parent 74086b1 commit 653981d

2 files changed

Lines changed: 143 additions & 59 deletions

File tree

examples/map-server/server.ts

Lines changed: 48 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,60 @@ export function createServer(): McpServer {
138138
},
139139
);
140140

141+
// show-map tool - displays the CesiumJS globe
142+
// Default bounding box: London area
143+
registerAppTool(
144+
server,
145+
"show-map",
146+
{
147+
title: "Show Map",
148+
description:
149+
"Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.",
150+
inputSchema: {
151+
west: z
152+
.number()
153+
.optional()
154+
.default(-0.5)
155+
.describe("Western longitude (-180 to 180)"),
156+
south: z
157+
.number()
158+
.optional()
159+
.default(51.3)
160+
.describe("Southern latitude (-90 to 90)"),
161+
east: z
162+
.number()
163+
.optional()
164+
.default(0.3)
165+
.describe("Eastern longitude (-180 to 180)"),
166+
north: z
167+
.number()
168+
.optional()
169+
.default(51.7)
170+
.describe("Northern latitude (-90 to 90)"),
171+
label: z
172+
.string()
173+
.optional()
174+
.describe("Optional label to display on the map"),
175+
},
176+
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
177+
},
178+
async ({ west, south, east, north, label }): Promise<CallToolResult> => ({
179+
content: [
180+
{
181+
type: "text",
182+
text: `Displaying globe at: W:${west.toFixed(4)}, S:${south.toFixed(4)}, E:${east.toFixed(4)}, N:${north.toFixed(4)}${label ? ` (${label})` : ""}`,
183+
},
184+
],
185+
}),
186+
);
187+
141188
// geocode tool - searches for places using Nominatim (no UI)
142189
server.registerTool(
143190
"geocode",
144191
{
145192
title: "Geocode",
146193
description:
147-
"Search for places using OpenStreetMap Nominatim. Returns coordinates and bounding boxes for up to 5 matches.",
194+
"Search for places using OpenStreetMap. Returns coordinates and bounding boxes for up to 5 matches.",
148195
inputSchema: {
149196
query: z
150197
.string()
@@ -203,53 +250,6 @@ export function createServer(): McpServer {
203250
},
204251
);
205252

206-
// show-map tool - displays the CesiumJS globe
207-
// Default bounding box: London area
208-
registerAppTool(
209-
server,
210-
"show-map",
211-
{
212-
title: "Show Map",
213-
description:
214-
"Display an interactive 3D globe zoomed to a specific bounding box. The globe uses OpenStreetMap tiles and supports rotation, zoom, and 3D perspective. Defaults to London if no coordinates provided.",
215-
inputSchema: {
216-
west: z
217-
.number()
218-
.optional()
219-
.default(-0.5)
220-
.describe("Western longitude (-180 to 180)"),
221-
south: z
222-
.number()
223-
.optional()
224-
.default(51.3)
225-
.describe("Southern latitude (-90 to 90)"),
226-
east: z
227-
.number()
228-
.optional()
229-
.default(0.3)
230-
.describe("Eastern longitude (-180 to 180)"),
231-
north: z
232-
.number()
233-
.optional()
234-
.default(51.7)
235-
.describe("Northern latitude (-90 to 90)"),
236-
label: z
237-
.string()
238-
.optional()
239-
.describe("Optional label to display on the map"),
240-
},
241-
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
242-
},
243-
async ({ west, south, east, north, label }): Promise<CallToolResult> => ({
244-
content: [
245-
{
246-
type: "text",
247-
text: `Displaying globe at: W:${west.toFixed(4)}, S:${south.toFixed(4)}, E:${east.toFixed(4)}, N:${north.toFixed(4)}${label ? ` (${label})` : ""}`,
248-
},
249-
],
250-
}),
251-
);
252-
253253
return server;
254254
}
255255

examples/map-server/src/mcp-app.ts

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ function getScaleDimensions(extent: BoundingBox): {
126126
return { widthKm, heightKm };
127127
}
128128

129+
// Rate limiting for Nominatim (1 request per second per their usage policy)
130+
let lastNominatimRequest = 0;
131+
const NOMINATIM_RATE_LIMIT_MS = 1100; // 1.1 seconds to be safe
132+
133+
/**
134+
* Wait for rate limit before making a Nominatim request
135+
*/
136+
async function waitForRateLimit(): Promise<void> {
137+
const now = Date.now();
138+
const timeSinceLastRequest = now - lastNominatimRequest;
139+
if (timeSinceLastRequest < NOMINATIM_RATE_LIMIT_MS) {
140+
await new Promise((resolve) =>
141+
setTimeout(resolve, NOMINATIM_RATE_LIMIT_MS - timeSinceLastRequest),
142+
);
143+
}
144+
lastNominatimRequest = Date.now();
145+
}
146+
129147
/**
130148
* Reverse geocode a single point using Nominatim
131149
* Returns the place name for that location
@@ -135,6 +153,7 @@ async function reverseGeocode(
135153
lon: number,
136154
): Promise<string | null> {
137155
try {
156+
await waitForRateLimit();
138157
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`;
139158
const response = await fetch(url, {
140159
headers: {
@@ -166,14 +185,81 @@ async function reverseGeocode(
166185
}
167186

168187
/**
169-
* Debounced location update using reverse geocoding
170-
* Gets the place name for center point and logs extent
188+
* Get sample points within an extent based on the visible area size.
189+
* For small areas (city zoom), just sample center.
190+
* For larger areas, sample center + corners to discover multiple places.
191+
*/
192+
function getSamplePoints(
193+
extent: BoundingBox,
194+
extentSizeKm: number,
195+
): Array<{ lat: number; lon: number }> {
196+
const centerLat = (extent.north + extent.south) / 2;
197+
const centerLon = (extent.east + extent.west) / 2;
198+
199+
// Always include center
200+
const points: Array<{ lat: number; lon: number }> = [
201+
{ lat: centerLat, lon: centerLon },
202+
];
203+
204+
// For larger extents, add more sample points
205+
if (extentSizeKm > 100) {
206+
// > 100km: sample 4 quadrant centers
207+
const latOffset = (extent.north - extent.south) / 4;
208+
const lonOffset = (extent.east - extent.west) / 4;
209+
points.push(
210+
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
211+
{ lat: centerLat + latOffset, lon: centerLon + lonOffset }, // NE
212+
{ lat: centerLat - latOffset, lon: centerLon - lonOffset }, // SW
213+
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
214+
);
215+
} else if (extentSizeKm > 30) {
216+
// 30-100km: sample 2 opposite corners
217+
const latOffset = (extent.north - extent.south) / 4;
218+
const lonOffset = (extent.east - extent.west) / 4;
219+
points.push(
220+
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
221+
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
222+
);
223+
}
224+
// < 30km: just center (likely same city)
225+
226+
return points;
227+
}
228+
229+
/**
230+
* Get places visible in the extent by sampling multiple points
231+
* Returns array of unique place names
232+
*/
233+
async function getVisiblePlaces(extent: BoundingBox): Promise<string[]> {
234+
const { widthKm, heightKm } = getScaleDimensions(extent);
235+
const extentSizeKm = Math.max(widthKm, heightKm);
236+
const samplePoints = getSamplePoints(extent, extentSizeKm);
237+
238+
log.info(
239+
`Sampling ${samplePoints.length} points for extent ${extentSizeKm.toFixed(0)}km`,
240+
);
241+
242+
const places = new Set<string>();
243+
for (const point of samplePoints) {
244+
const place = await reverseGeocode(point.lat, point.lon);
245+
if (place) {
246+
places.add(place);
247+
log.info(`Found place: ${place} at ${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`);
248+
}
249+
}
250+
251+
return [...places];
252+
}
253+
254+
/**
255+
* Debounced location update using multi-point reverse geocoding
256+
* Samples multiple points in the visible extent to discover places
171257
*/
172258
function scheduleLocationUpdate(cesiumViewer: any): void {
173259
if (reverseGeocodeTimer) {
174260
clearTimeout(reverseGeocodeTimer);
175261
}
176-
// Debounce to 1.5 seconds (Nominatim rate limit is 1 req/sec)
262+
// Debounce to 1.5 seconds before starting geocoding
177263
reverseGeocodeTimer = setTimeout(async () => {
178264
const center = getCameraCenter(cesiumViewer);
179265
const extent = getVisibleExtent(cesiumViewer);
@@ -190,19 +276,17 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
190276
`(${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km)`;
191277
log.info(extentInfo);
192278

193-
// Reverse geocode the center point
194-
let placeName: string | null = null;
195-
if (center) {
196-
placeName = await reverseGeocode(center.lat, center.lon);
197-
}
198-
const placeText = placeName ? `Location: ${placeName}` : "";
279+
// Get places visible in the extent (samples multiple points for large areas)
280+
const places = await getVisiblePlaces(extent);
281+
const placesText =
282+
places.length > 0 ? `Visible places: ${places.join(", ")}` : "";
199283

200-
if (placeName || center) {
284+
if (places.length > 0 || center) {
201285
const centerText = center
202286
? `Center: ${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}`
203287
: "";
204288

205-
const contextText = [placeText, centerText, extentInfo]
289+
const contextText = [placesText, centerText, extentInfo]
206290
.filter(Boolean)
207291
.join("\n");
208292

0 commit comments

Comments
 (0)