mapnetwork-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,430 @@
1
+ """MCP server for MapNetwork — generates styled map images from a place name or coordinates."""
2
+
3
+ import asyncio
4
+ import re
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import httpx
10
+ from mcp.server.fastmcp import FastMCP, Image
11
+
12
+ BASE_URL = "https://mapnetwork.app"
13
+ POLL_INTERVAL_SEC = 2.0
14
+ MAX_WAIT_SEC = 120
15
+
16
+ mcp = FastMCP(
17
+ "MapNetwork",
18
+ instructions=(
19
+ "You have access to MapNetwork, which generates styled map images (PNG or SVG) "
20
+ "for any location on Earth.\n\n"
21
+ "## Key capabilities\n"
22
+ "- **Single location**: pass `place` (geocoded server-side) or `lat`/`lng` as the map center\n"
23
+ "- **Multiple locations**: pass `markers` — a list of places to pin on the map. "
24
+ "Each marker needs only a `label` (geocoded automatically); explicit `lat`/`lng` is optional. "
25
+ "When `markers` are given without a `place`/`lat`/`lng` center, the server derives the center "
26
+ "from the centroid of the markers and sets the radius automatically (1.2× farthest marker distance). "
27
+ "Use this whenever the user asks to show multiple places on a single map.\n"
28
+ "- **Circular area**: specify `radius` in meters (default 500, max 2500) around the center\n"
29
+ "- **Rectangular area**: specify `size_ew` (east-west) and `size_ns` (north-south) in meters "
30
+ "instead of radius — useful when the area of interest is not square\n"
31
+ "- **Layers**: choose which features appear. "
32
+ "Use EXACTLY these values (singular, no trailing 's'): "
33
+ "'road', 'highway', 'driving', 'walking', 'railway', 'waterline', 'poi'. "
34
+ "Wrong: 'roads', 'railways', 'waterlines' — these will be rejected by the server.\n"
35
+ "- **Color themes** via `color_set`: "
36
+ "white (clean, default), darkBlue (navy bg), darkGreen (dark teal bg), "
37
+ "popArt (blue bg, bold contrast), lightBlue (pale blue bg), lightGreen (pale green bg), "
38
+ "beige (warm peach bg), magenta (hot-pink bg), gray (monochrome), "
39
+ "black (dark mode), brawn (dark brown/earthy bg)\n"
40
+ "- **SVG output**: request `format='svg'` for a vector file instead of PNG\n\n"
41
+ "## Route overlay\n"
42
+ "To show a walking or driving route on a map:\n"
43
+ "1. Call `compute_route` with `from_location` and `to_location` (place name or coords)\n"
44
+ "2. Call `generate_map` with `route=<result>` and `markers=[result['from'], result['to']]`\n"
45
+ "The route is drawn on top of all other layers. "
46
+ "Add `'color': '#RRGGBB'` to the route dict to customise the line color.\n\n"
47
+ "## Important: re-download without regenerating\n"
48
+ "After generating a map, `generate_map` returns a `dataKey`. "
49
+ "You can call `redownload_map` with that `dataKey` to get the same map in a different "
50
+ "format (png/svg) or color theme — **no regeneration needed**. "
51
+ "Use this when the user asks to change only the appearance after already generating the map.\n\n"
52
+ "## Open in the MapNetwork editor\n"
53
+ "Any generated map can be opened and edited interactively in the MapNetwork web UI at:\n"
54
+ " https://mapnetwork.app/edit?dataKey=<dataKey>\n"
55
+ "Mention this URL when the user might want to customize markers, colors, or layout manually. "
56
+ "The map data format is identical to what the UI produces when uploading data.\n\n"
57
+ "## Color themes\n"
58
+ "MapNetwork supports 11 color themes via color_set (white, darkBlue, darkGreen, popArt, lightBlue, "
59
+ "lightGreen, beige, magenta, gray, black, brawn). "
60
+ "Mention this when relevant, but do not ask the user unprompted. "
61
+ "Only set color_set when the user explicitly requests a theme.\n\n"
62
+ "## Parameter discipline\n"
63
+ "- **lat/lng**: Never guess or estimate coordinates from training data. "
64
+ "If the location is known only by name, use `place` and let the server geocode it.\n"
65
+ "- **radius / size_ew / size_ns**: Do not set these unless the user has explicitly asked for "
66
+ "a specific map range or shape. Omit them to let the server apply its default (500 m radius)."
67
+ ),
68
+ )
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Helpers
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def _make_filename(label: str, format: str) -> Path:
75
+ slug = re.sub(r"_+", "_", re.sub(r"[^\w぀-鿿]", "_", label)).strip("_")[:40]
76
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
77
+ out_dir = Path.home() / "Downloads"
78
+ out_dir.mkdir(exist_ok=True)
79
+ return out_dir / f"map_{slug}_{timestamp}.{format}"
80
+
81
+
82
+ async def _download(client: httpx.AsyncClient, data_key: str, format: str,
83
+ color_set: str | None, canvas_width: int | None, canvas_height: int | None,
84
+ edge_weight: int | None = None) -> bytes:
85
+ params: dict = {"dataKey": data_key, "format": format}
86
+ if color_set is not None:
87
+ params["colorSet"] = color_set
88
+ if canvas_width is not None:
89
+ params["canvasWidth"] = canvas_width
90
+ if canvas_height is not None:
91
+ params["canvasHeight"] = canvas_height
92
+ if edge_weight is not None:
93
+ params["edgeWeight"] = edge_weight
94
+ resp = await client.get(f"{BASE_URL}/download", params=params, timeout=60)
95
+ if resp.status_code != 200:
96
+ raise RuntimeError(f"Download failed ({resp.status_code}): {resp.text}")
97
+ return resp.content
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Tool: compute_route
102
+ # ---------------------------------------------------------------------------
103
+
104
+ @mcp.tool()
105
+ async def compute_route(
106
+ from_location: Annotated[
107
+ dict,
108
+ "Start point of the route. "
109
+ "Use {\"label\": \"place name\"} to geocode server-side, "
110
+ "or {\"label\": \"...\", \"location\": {\"lat\": ..., \"lng\": ...}} to supply explicit coordinates. "
111
+ "Example: {\"label\": \"赤坂駅\"}",
112
+ ],
113
+ to_location: Annotated[
114
+ dict,
115
+ "End point of the route. Same format as from_location. "
116
+ "Example: {\"label\": \"赤坂氷川神社\"}",
117
+ ],
118
+ mode: Annotated[
119
+ str,
120
+ "Routing mode: 'walking' (default, footpaths and streets) or 'driving' (car-accessible roads).",
121
+ ] = "walking",
122
+ ) -> dict:
123
+ """Compute a walking or driving route between two locations.
124
+
125
+ Returns the route as an ordered list of coordinates, plus the resolved from/to locations.
126
+ Pass the result directly to generate_map's `route` parameter to overlay it on a map image.
127
+ Use from/to as `markers` in generate_map to place pins at the start and end points.
128
+
129
+ Typical flow:
130
+ 1. route = compute_route(from_location={"label": "A"}, to_location={"label": "B"})
131
+ 2. generate_map(route=route, markers=[route["from"], route["to"]], ...)
132
+ """
133
+ if mode not in ("walking", "driving"):
134
+ raise ValueError("mode must be 'walking' or 'driving'")
135
+
136
+ body = {"from": from_location, "to": to_location, "mode": mode}
137
+ async with httpx.AsyncClient() as client:
138
+ resp = await client.post(f"{BASE_URL}/route", json=body, timeout=180)
139
+ if resp.status_code == 404:
140
+ raise RuntimeError("No route found between the two locations.")
141
+ if resp.status_code == 504:
142
+ raise RuntimeError("Route computation timed out. Try a shorter distance or different locations.")
143
+ if resp.status_code != 200:
144
+ try:
145
+ detail = resp.json().get("error", resp.text)
146
+ except Exception:
147
+ detail = resp.text
148
+ raise RuntimeError(f"Route request failed ({resp.status_code}): {detail}")
149
+ return resp.json()
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Tool: generate_map
154
+ # ---------------------------------------------------------------------------
155
+
156
+ @mcp.tool()
157
+ async def generate_map(
158
+ place: Annotated[
159
+ str | None,
160
+ "Place name to center the map on (e.g. 'Shibuya Station', '東京駅', 'Eiffel Tower'). "
161
+ "A red pin is placed at the resolved location. Use this OR lat/lng, not both. "
162
+ "Omit when using markers-only mode.",
163
+ ] = None,
164
+ lat: Annotated[
165
+ float | None,
166
+ "Latitude of the map center. Must be combined with lng. "
167
+ "ONLY set this when you have a precise, verified coordinate value — never guess or estimate. "
168
+ "If the location is known only by name, use `place` instead and let the server geocode it. "
169
+ "Omit when using markers-only mode.",
170
+ ] = None,
171
+ lng: Annotated[
172
+ float | None,
173
+ "Longitude of the map center. Must be combined with lat. "
174
+ "ONLY set this when you have a precise, verified coordinate value — never guess or estimate. "
175
+ "If the location is known only by name, use `place` instead and let the server geocode it. "
176
+ "Omit when using markers-only mode.",
177
+ ] = None,
178
+ markers: Annotated[
179
+ list[dict] | None,
180
+ "List of locations to pin on the map as blue markers. "
181
+ "Each item: {\"label\": \"place name\"} — the server geocodes it automatically. "
182
+ "Optionally include explicit coordinates: {\"label\": \"...\", \"location\": {\"lat\": ..., \"lng\": ...}}. "
183
+ "Each marker may also include an optional \"icon\" field to override its appearance: "
184
+ "{\"label\": \"...\", \"icon\": {\"color\": \"#ff0000\"}}. "
185
+ "All icon sub-fields (code/size/color) are optional — omit any to keep the default. "
186
+ "When markers are provided without place/lat/lng, the server derives the map center from their centroid "
187
+ "and sets radius automatically. "
188
+ "Example for two stations: [{\"label\": \"東京駅\"}, {\"label\": \"新宿駅\"}]",
189
+ ] = None,
190
+ place_icon: Annotated[
191
+ dict | None,
192
+ "Custom icon for the `place` pin (or center pin if no `place`). "
193
+ "All fields are optional — specify only what you want to override. "
194
+ "Fields: code (FontAwesome \\uXXXX), size (pixels), color (CSS hex). "
195
+ "Example (red icon, larger): {\"color\": \"#ff0000\", \"size\": 50}. "
196
+ "Omit entirely to use the default icon.",
197
+ ] = None,
198
+ name: Annotated[
199
+ str | None,
200
+ "Map name stored in the data file. Used as the default download filename. "
201
+ "Omit to leave unnamed.",
202
+ ] = None,
203
+ title: Annotated[
204
+ str | None,
205
+ "Optional title text rendered in the bottom-left corner of the map image. "
206
+ "Use when the user wants a label on the map (e.g. the place name or a description). "
207
+ "Omit to generate an untitled map.",
208
+ ] = None,
209
+ radius: Annotated[
210
+ int | None,
211
+ "Circular coverage radius in meters (max 2500). "
212
+ "Mutually exclusive with size_ew/size_ns. "
213
+ "ONLY set this when the user has explicitly requested a specific coverage range. "
214
+ "If the user has not specified a range, omit it — the server applies a sensible default (500 m).",
215
+ ] = None,
216
+ size_ew: Annotated[
217
+ float | None,
218
+ "East-west width of a rectangular coverage area in meters. "
219
+ "Must be combined with size_ns. Mutually exclusive with radius. "
220
+ "ONLY set this when the user has explicitly requested a rectangular area of specific dimensions. "
221
+ "Do not infer dimensions — omit and let the server use its default if the user has not specified.",
222
+ ] = None,
223
+ size_ns: Annotated[
224
+ float | None,
225
+ "North-south height of a rectangular coverage area in meters. "
226
+ "Must be combined with size_ew. Mutually exclusive with radius. "
227
+ "ONLY set this when the user has explicitly requested a rectangular area of specific dimensions.",
228
+ ] = None,
229
+ layers: Annotated[
230
+ list[str] | None,
231
+ "Map layers to render. Default ['road', 'poi']. "
232
+ "IMPORTANT: use EXACTLY these singular spellings — never add 's' or change the spelling:\n"
233
+ "'road' — all roads (supersedes highway/driving/walking; no need to add them when road is present)\n"
234
+ "'highway' — major roads only\n"
235
+ "'driving' — car-accessible roads\n"
236
+ "'walking' — footpaths and pedestrian walkways\n"
237
+ "'railway' — train and subway lines\n"
238
+ "'waterline' — rivers, lakes, and coastlines\n"
239
+ "'poi' — point-of-interest icons (filterable with poi_types)\n"
240
+ "Example for road + railway + POI: ['road', 'railway', 'poi']",
241
+ ] = None,
242
+ poi_types: Annotated[
243
+ list[str] | None,
244
+ "Filter POI categories when 'poi' is in layers. Omit to include all. "
245
+ "Valid values: museum, library, theatre, convenience, supermarket, "
246
+ "school, religion, station, park, hospital, cityhall, cafe, restaurant.",
247
+ ] = None,
248
+ route: Annotated[
249
+ dict | None,
250
+ "Route to overlay on the map. Pass the full response from compute_route() directly — "
251
+ "the server uses coords and (optionally) color from it. "
252
+ "To customise the line color, add a 'color' key: {**route_result, 'color': '#FF4500'}. "
253
+ "Omit to generate a map without a route overlay.",
254
+ ] = None,
255
+ color_set: Annotated[
256
+ str | None,
257
+ "Color theme for the map. Only set when the user explicitly requests one. "
258
+ "Baked into the stored map data so redownload_map uses the same theme by default. "
259
+ "Can be overridden per-download by passing color_set to redownload_map. "
260
+ "Available themes: white, darkBlue, darkGreen, popArt, lightBlue, lightGreen, beige, magenta, gray, black, brawn.",
261
+ ] = None,
262
+ format: Annotated[
263
+ str,
264
+ "Output file format: 'png' (default, raster image) or 'svg' (vector, scalable to any size).",
265
+ ] = "png",
266
+ canvas_width: Annotated[int | None, "Canvas width in pixels. Omit to use server default (1000)."] = None,
267
+ canvas_height: Annotated[int | None, "Canvas height in pixels. Omit to use server default (700)."] = None,
268
+ edge_weight: Annotated[
269
+ int | None,
270
+ "Road and line width adjustment. Positive values thicken lines, negative values thin them. "
271
+ "Default 0 (no adjustment). Example: 2 to make roads slightly thicker, -1 to make them thinner.",
272
+ ] = None,
273
+ ) -> list:
274
+ """Generate a styled map image for a given location and save it to Downloads.
275
+
276
+ Coverage shape:
277
+ - Default (no radius, no size): circular 500 m radius
278
+ - radius=N: circular, N meters
279
+ - size_ew + size_ns: rectangular bounding box
280
+
281
+ After generation the dataKey is returned in the text result.
282
+ Use redownload_map(dataKey=...) to get the same map in a different color or format
283
+ without waiting for regeneration.
284
+ """
285
+ has_center = place or (lat is not None and lng is not None)
286
+ if not has_center and not markers:
287
+ raise ValueError("Specify 'place', both 'lat'+'lng', or 'markers'.")
288
+ if radius is not None and (size_ew is not None or size_ns is not None):
289
+ raise ValueError("Specify either 'radius' or 'size_ew'+'size_ns', not both.")
290
+ if (size_ew is None) != (size_ns is None):
291
+ raise ValueError("'size_ew' and 'size_ns' must be specified together.")
292
+
293
+ body: dict = {}
294
+ if layers is not None:
295
+ body["layers"] = layers
296
+ if place:
297
+ body["place"] = place
298
+ elif lat is not None and lng is not None:
299
+ body["center"] = {"lat": lat, "lng": lng}
300
+ if markers is not None:
301
+ body["markers"] = markers
302
+ if place_icon is not None:
303
+ body["icon"] = place_icon
304
+ if name is not None:
305
+ body["name"] = name
306
+ if title is not None:
307
+ body["title"] = title
308
+ if color_set is not None:
309
+ body["colorSet"] = color_set
310
+ if radius is not None:
311
+ body["radius"] = radius
312
+ elif size_ew is not None:
313
+ body["size"] = {"ew": size_ew, "ns": size_ns}
314
+ if poi_types:
315
+ body["poiTypes"] = poi_types
316
+ if route is not None:
317
+ body["route"] = route
318
+
319
+ async with httpx.AsyncClient() as client:
320
+ # 1. Enqueue
321
+ resp = await client.post(f"{BASE_URL}/request", json=body, timeout=30)
322
+ if resp.status_code != 202:
323
+ try:
324
+ detail = resp.json().get("error", resp.text)
325
+ except Exception:
326
+ detail = resp.text
327
+ raise RuntimeError(f"Request rejected ({resp.status_code}): {detail}")
328
+ data_key = resp.json()["dataKey"]
329
+
330
+ # 2. Poll until ready
331
+ elapsed = 0.0
332
+ while elapsed < MAX_WAIT_SEC:
333
+ await asyncio.sleep(POLL_INTERVAL_SEC)
334
+ elapsed += POLL_INTERVAL_SEC
335
+ status_resp = await client.get(
336
+ f"{BASE_URL}/status", params={"dataKey": data_key}, timeout=10
337
+ )
338
+ status = status_resp.json().get("status")
339
+ if status == "ready":
340
+ break
341
+ if status == "failed":
342
+ raise RuntimeError(
343
+ f"Map generation failed (dataKey={data_key}). "
344
+ "Try a different location or smaller radius."
345
+ )
346
+ else:
347
+ raise TimeoutError(f"Map generation timed out after {MAX_WAIT_SEC}s.")
348
+
349
+ # 3. Download
350
+ image_bytes = await _download(client, data_key, format, color_set,
351
+ canvas_width, canvas_height, edge_weight)
352
+
353
+ if place:
354
+ label = place
355
+ elif lat is not None and lng is not None:
356
+ label = f"{lat}_{lng}"
357
+ else:
358
+ label = "_".join(m.get("label", "") for m in (markers or []))[:40]
359
+ out_path = _make_filename(label, format)
360
+ out_path.write_bytes(image_bytes)
361
+
362
+ return [
363
+ Image(data=image_bytes, format="png" if format == "png" else "png"),
364
+ (
365
+ f"Map saved: {out_path}\n"
366
+ f"dataKey: {data_key}\n\n"
367
+ f"Open in editor: https://mapnetwork.app/edit?dataKey={data_key}\n\n"
368
+ f"Tip: call redownload_map(data_key='{data_key}', ...) to get this map "
369
+ f"in a different color_set or format (svg/png) without regenerating."
370
+ ),
371
+ ]
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Tool: redownload_map
376
+ # ---------------------------------------------------------------------------
377
+
378
+ @mcp.tool()
379
+ async def redownload_map(
380
+ data_key: Annotated[
381
+ str,
382
+ "The dataKey returned by a previous generate_map call (e.g. '20260618aBcDeFgHiJ'). "
383
+ "The map data is reused — no regeneration occurs.",
384
+ ],
385
+ color_set: Annotated[
386
+ str | None,
387
+ "Color theme for the map. Only set when the user explicitly requests one. "
388
+ "Available: white, darkBlue, darkGreen, popArt, lightBlue, lightGreen, beige, magenta, gray, black, brawn.",
389
+ ] = None,
390
+ format: Annotated[
391
+ str,
392
+ "'png' (raster) or 'svg' (vector, infinitely scalable).",
393
+ ] = "png",
394
+ canvas_width: Annotated[int | None, "Canvas width in pixels. Omit to use server default (1000)."] = None,
395
+ canvas_height: Annotated[int | None, "Canvas height in pixels. Omit to use server default (700)."] = None,
396
+ edge_weight: Annotated[
397
+ int | None,
398
+ "Road and line width adjustment. Positive values thicken lines, negative values thin them. "
399
+ "Default 0 (no adjustment).",
400
+ ] = None,
401
+ ) -> list:
402
+ """Re-download a previously generated map with a different color theme or format.
403
+
404
+ Uses the dataKey from a prior generate_map call. The map data is NOT regenerated,
405
+ so this is instant. Use this when the user wants to:
406
+ - Try a different color theme (e.g. 'black' for dark mode, 'lightBlue', 'popArt')
407
+ - Get an SVG version of a map already generated as PNG
408
+ - Get a larger or smaller canvas size
409
+ """
410
+ async with httpx.AsyncClient() as client:
411
+ image_bytes = await _download(client, data_key, format, color_set,
412
+ canvas_width, canvas_height, edge_weight)
413
+
414
+ out_path = _make_filename(data_key, format)
415
+ out_path.write_bytes(image_bytes)
416
+
417
+ return [
418
+ Image(data=image_bytes, format="png"),
419
+ f"Map saved: {out_path} (dataKey={data_key}, colorSet={color_set}, format={format})",
420
+ ]
421
+
422
+
423
+ # ---------------------------------------------------------------------------
424
+
425
+ def main() -> None:
426
+ mcp.run()
427
+
428
+
429
+ if __name__ == "__main__":
430
+ main()
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: mapnetwork-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server — generate styled map images via mapnetwork.app
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: mcp[cli]>=1.0.0
@@ -0,0 +1,6 @@
1
+ mapnetwork_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ mapnetwork_mcp/server.py,sha256=z0-I4TFJoc4-KDmEh1LVKbcUXIa_fmPV6iaoEDHSaY4,20433
3
+ mapnetwork_mcp-0.1.0.dist-info/METADATA,sha256=B7o7neHm00f4iqLgp0yEVNpC1zNrVXBhbjyWtUqDU_4,212
4
+ mapnetwork_mcp-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ mapnetwork_mcp-0.1.0.dist-info/entry_points.txt,sha256=bl94V7Gk_Jy98Q9g9jUog2EEkR0n2bKQGbq-aLWrY6U,62
6
+ mapnetwork_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mapnetwork-mcp = mapnetwork_mcp.server:main