map-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.
map_mcp/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """map-mcp — drive and perceive an existing live MapLibre GL map from an agent or a human CLI.
2
+
3
+ A map app opts in with a small JS hook that registers its live map and connects out to a
4
+ local WebSocket this process runs. The MCP tools (for agents) and the CLI (for humans) are
5
+ thin frontends over one shared core-operations layer, so any operation one can do, the other
6
+ can too (parity by construction). v1 is MapLibre-only and drives *existing* maps — it does not
7
+ generate maps.
8
+ """
9
+
10
+ __version__ = "0.1.0"
map_mcp/bridge.py ADDED
@@ -0,0 +1,202 @@
1
+ """The local WebSocket bridge between this process and the in-browser map hook.
2
+
3
+ ``call(method, params)`` sends a request to the connected hook and blocks until the matching
4
+ reply arrives (correlated by id). The correlation logic is synchronous and unit-testable
5
+ without a socket — feed replies via ``_on_message``. The live WebSocket server is gated on the
6
+ ``websockets`` library, binds loopback only, and (with U6) requires a per-session token.
7
+
8
+ Sync-over-async: the ws server runs in a daemon thread with its own event loop; ``call`` (on
9
+ the MCP/CLI thread) waits on a threading event the receive coroutine sets. This keeps core-ops
10
+ and the MCP tools synchronous, matching streamlit-mcp's engine.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import secrets
16
+ import threading
17
+ from typing import Any, Callable, Optional
18
+
19
+ from .protocol import decode, encode, make_request
20
+
21
+
22
+ class BridgeError(RuntimeError):
23
+ """Raised when no map is connected, a call times out, or the hook returns an error."""
24
+
25
+
26
+ def ws_available() -> bool:
27
+ try:
28
+ import websockets # noqa: F401
29
+ except Exception:
30
+ return False
31
+ return True
32
+
33
+
34
+ def authenticate(first_message: str, expected_token: Optional[str]) -> bool:
35
+ """Validate the hook's opening ``{type:"hello", token}`` frame against the session token.
36
+
37
+ Pure (no I/O), so the handshake rule is unit-testable. When no token is configured
38
+ (``expected_token`` falsy) the gate is open — but the CLI always issues a token."""
39
+ if not expected_token:
40
+ return True
41
+ try:
42
+ msg = decode(first_message)
43
+ except Exception:
44
+ return False
45
+ if msg.get("type") != "hello":
46
+ return False
47
+ # constant-time compare so token validation leaks no timing signal
48
+ return secrets.compare_digest(str(msg.get("token") or ""), str(expected_token))
49
+
50
+
51
+ class _Pending:
52
+ __slots__ = ("event", "result", "error")
53
+
54
+ def __init__(self) -> None:
55
+ self.event = threading.Event()
56
+ self.result: Any = None
57
+ self.error: Optional[str] = None
58
+
59
+
60
+ class Bridge:
61
+ """Correlates outbound requests with inbound replies by id. Transport-agnostic."""
62
+
63
+ def __init__(self) -> None:
64
+ self._pending: dict[Any, _Pending] = {}
65
+ self._lock = threading.Lock()
66
+ self._sender: Optional[Callable[[str], None]] = None
67
+ self._thread: Optional[threading.Thread] = None
68
+ self.port: Optional[int] = None
69
+ self._loop: Any = None
70
+
71
+ @property
72
+ def connected(self) -> bool:
73
+ return self._sender is not None
74
+
75
+ def attach_sender(self, sender: Callable[[str], None]) -> None:
76
+ """Bind the function that writes a frame to the connected hook (last writer wins —
77
+ a reconnect/new tab replaces the old; the old's in-flight calls then time out)."""
78
+ self._sender = sender
79
+
80
+ def detach(self, sender: Optional[Callable[[str], None]] = None) -> None:
81
+ """Clear the sender on disconnect. Identity-guarded: a stale connection's teardown
82
+ only clears the sender if it is still the *current* one — so a page reload (new
83
+ socket attaches, then the old socket's close fires) can't null the live sender.
84
+ Draining lets in-flight calls fail fast with 'map disconnected' instead of timing out."""
85
+ if sender is None or self._sender is sender:
86
+ self._sender = None
87
+ self._drain_pending("map disconnected")
88
+
89
+ def _drain_pending(self, message: str) -> None:
90
+ with self._lock:
91
+ waiting = list(self._pending.values())
92
+ for pend in waiting:
93
+ if pend.error is None and pend.result is None:
94
+ pend.error = message
95
+ pend.event.set()
96
+
97
+ def call(self, method: str, params: Optional[dict] = None, *, timeout: float = 10.0) -> Any:
98
+ """Send a request and block for its reply. Raises BridgeError on no-connection,
99
+ send failure, timeout, disconnect, or an error reply — never leaks another exception."""
100
+ sender = self._sender
101
+ if sender is None:
102
+ raise BridgeError("no map connected — open a hooked map and try again")
103
+ req = make_request(method, params)
104
+ pend = _Pending()
105
+ with self._lock:
106
+ self._pending[req["id"]] = pend
107
+ try:
108
+ try:
109
+ sender(encode(req))
110
+ except Exception as e: # closed loop / dropped socket -> clean BridgeError, not a leak
111
+ raise BridgeError(f"send failed: {e}") from e
112
+ if not pend.event.wait(timeout):
113
+ raise BridgeError(f"timeout after {timeout}s waiting for {method!r}")
114
+ if pend.error is not None:
115
+ raise BridgeError(pend.error)
116
+ return pend.result
117
+ finally:
118
+ with self._lock:
119
+ self._pending.pop(req["id"], None)
120
+
121
+ def _on_message(self, text: str) -> None:
122
+ """Resolve the pending call matching this reply's id. Unknown/stale ids are ignored."""
123
+ try:
124
+ msg = decode(text)
125
+ except Exception:
126
+ return
127
+ with self._lock:
128
+ pend = self._pending.get(msg.get("id"))
129
+ if pend is None:
130
+ return
131
+ if msg.get("ok"):
132
+ pend.result = msg.get("result")
133
+ else:
134
+ pend.error = msg.get("error") or "unknown error from hook"
135
+ pend.event.set()
136
+
137
+ # ------------------------------------------------------------- live server
138
+ def serve_ws(self, host: str = "127.0.0.1", port: int = 8765,
139
+ token: Optional[str] = None) -> None:
140
+ """Start the loopback WebSocket server in a daemon thread (non-blocking).
141
+
142
+ Gated on ``websockets``. Binds loopback only. The token handshake is layered in U6;
143
+ v1 here wires connect -> sender, and routes inbound frames to ``_on_message``.
144
+ """
145
+ if not ws_available():
146
+ raise BridgeError("websockets not installed — `pip install map-mcp` includes it")
147
+ if host not in ("127.0.0.1", "::1", "localhost"):
148
+ raise BridgeError(f"refusing to bind non-loopback host {host!r} (local-only)")
149
+
150
+ import asyncio
151
+
152
+ import websockets
153
+
154
+ loop = asyncio.new_event_loop()
155
+ self._loop = loop
156
+ ready = threading.Event()
157
+ startup: dict[str, BaseException] = {}
158
+
159
+ async def handler(ws):
160
+ # Token handshake (local-only security): require a valid hello before accepting
161
+ # commands, so a stray page on the loopback port can't drive the operator's map.
162
+ if token:
163
+ try:
164
+ first = await asyncio.wait_for(ws.recv(), timeout=10)
165
+ except Exception:
166
+ return
167
+ if not authenticate(first, token):
168
+ await ws.close(code=4001, reason="bad token")
169
+ return
170
+ send = lambda text: asyncio.run_coroutine_threadsafe(ws.send(text), loop)
171
+ self.attach_sender(send)
172
+ try:
173
+ async for message in ws:
174
+ self._on_message(message)
175
+ finally:
176
+ self.detach(send) # identity-guarded: only clears if still the current sender
177
+
178
+ async def main():
179
+ async with websockets.serve(handler, host, port) as server:
180
+ try:
181
+ self.port = server.sockets[0].getsockname()[1]
182
+ except Exception:
183
+ self.port = port
184
+ ready.set()
185
+ await asyncio.Future() # serve forever
186
+
187
+ def run():
188
+ asyncio.set_event_loop(loop)
189
+ try:
190
+ loop.run_until_complete(main())
191
+ except BaseException as e: # bind failure (port in use), etc.
192
+ startup["error"] = e
193
+ ready.set() # unblock the waiter so it can surface the error
194
+
195
+ self._thread = threading.Thread(target=run, daemon=True, name="map-mcp-bridge")
196
+ self._thread.start()
197
+ # Don't return until the server is actually listening — and surface startup failure
198
+ # (a port-in-use bind error would otherwise die silently in the thread).
199
+ if not ready.wait(timeout=5):
200
+ raise BridgeError("bridge did not start within 5s")
201
+ if "error" in startup:
202
+ raise BridgeError(f"bridge failed to start: {startup['error']}")
map_mcp/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """map-mcp CLI — the human-first surface.
2
+
3
+ `serve` starts the bridge + the MCP server (for agents) and prints the opt-in snippet + token.
4
+ `call` lets a human drive the live map from the terminal. Both paths go through the same
5
+ ``CoreOps`` an agent uses over MCP, so parity holds by construction — ``run_op`` accepts exactly
6
+ the operations the MCP tools expose.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import secrets
14
+ import sys
15
+ import time
16
+ from typing import Any, Optional
17
+
18
+ from .bridge import Bridge, BridgeError
19
+ from .coreops import OP_NAMES, CoreOps
20
+
21
+
22
+ def _force_utf8_output() -> None:
23
+ for stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except Exception:
27
+ pass
28
+
29
+
30
+ def run_op(ops: CoreOps, op: str, params: Optional[dict] = None) -> dict:
31
+ """Dispatch one operation by name through core-ops. Fail-closed, like the MCP tools.
32
+
33
+ This is the parity seam: it accepts exactly ``OP_NAMES`` — the same surface the agent has."""
34
+ if op not in OP_NAMES:
35
+ return {"error": f"unknown operation {op!r}; expected one of {list(OP_NAMES)}"}
36
+ try:
37
+ result = getattr(ops, op)(**(params or {}))
38
+ except (BridgeError, ValueError, TypeError) as e:
39
+ return {"error": str(e)}
40
+ return result if isinstance(result, dict) else {"result": result}
41
+
42
+
43
+ def _snippet(ws_port: int, token: str) -> str:
44
+ url = f"ws://127.0.0.1:{ws_port}"
45
+ return (
46
+ "Add this to your MapLibre page (the map is your existing maplibregl.Map):\n"
47
+ ' <script src="map-mcp-hook.js"></script>\n'
48
+ " <script>\n"
49
+ f' mapMcp.register(map, {{ url: "{url}", token: "{token}" }});\n'
50
+ " </script>\n"
51
+ f" bridge: {url} token: {token}"
52
+ )
53
+
54
+
55
+ # --------------------------------------------------------------------- commands
56
+ def _start_bridge(ws_port: int) -> tuple[Bridge, str]:
57
+ """Start the loopback bridge with a fresh per-session token. Raises BridgeError on
58
+ startup failure (e.g. port in use). Shared by serve and call."""
59
+ token = secrets.token_urlsafe(16)
60
+ bridge = Bridge()
61
+ bridge.serve_ws(host="127.0.0.1", port=ws_port, token=token)
62
+ return bridge, token
63
+
64
+
65
+ def cmd_serve(args: argparse.Namespace) -> int:
66
+ from .server import run_server, validate_transport
67
+ try:
68
+ validate_transport(args.transport)
69
+ except ValueError as e:
70
+ print(str(e), file=sys.stderr)
71
+ return 1
72
+ try:
73
+ bridge, token = _start_bridge(args.ws_port)
74
+ except Exception as e:
75
+ print(f"could not start the map bridge: {e}", file=sys.stderr)
76
+ return 1
77
+ print(_snippet(bridge.port, token), file=sys.stderr)
78
+ try:
79
+ run_server(bridge, transport=args.transport, host=args.host, port=args.port)
80
+ except ValueError as e:
81
+ print(str(e), file=sys.stderr)
82
+ return 1
83
+ return 0
84
+
85
+
86
+ def _wait_for_map(bridge: Bridge, timeout: float) -> bool:
87
+ deadline = time.monotonic() + timeout
88
+ while time.monotonic() < deadline:
89
+ if bridge.connected:
90
+ return True
91
+ time.sleep(0.1)
92
+ return bridge.connected
93
+
94
+
95
+ def cmd_call(args: argparse.Namespace) -> int:
96
+ try:
97
+ params = json.loads(args.params) if args.params else {}
98
+ except json.JSONDecodeError as e:
99
+ print(f"bad --params JSON: {e}", file=sys.stderr)
100
+ return 1
101
+ try:
102
+ bridge, token = _start_bridge(args.ws_port)
103
+ except Exception as e:
104
+ print(f"could not start the map bridge: {e}", file=sys.stderr)
105
+ return 1
106
+ print(_snippet(bridge.port, token), file=sys.stderr)
107
+ if not _wait_for_map(bridge, args.wait):
108
+ print("no map connected — open your hooked map, then retry", file=sys.stderr)
109
+ return 1
110
+ result = run_op(CoreOps(bridge), args.op, params)
111
+ print(json.dumps(result, indent=2, default=str))
112
+ return 1 if "error" in result else 0
113
+
114
+
115
+ # --------------------------------------------------------------------- parser
116
+ def build_parser() -> argparse.ArgumentParser:
117
+ parser = argparse.ArgumentParser(
118
+ prog="map-mcp",
119
+ description="Drive and perceive an existing live MapLibre GL map (agent via MCP, or here).")
120
+ sub = parser.add_subparsers(dest="command", required=True)
121
+
122
+ p_serve = sub.add_parser("serve", help="serve the live map over MCP (for agents)")
123
+ p_serve.add_argument("--transport", choices=["stdio", "http", "sse"], default="stdio")
124
+ p_serve.add_argument("--host", default="127.0.0.1")
125
+ p_serve.add_argument("--port", type=int, default=8000, help="MCP HTTP/SSE port")
126
+ p_serve.add_argument("--ws-port", dest="ws_port", type=int, default=8765,
127
+ help="bridge WebSocket port the hook connects to")
128
+ p_serve.set_defaults(func=cmd_serve)
129
+
130
+ p_call = sub.add_parser("call", help="run one operation against the live map (same ops agents use)")
131
+ p_call.add_argument("op", choices=list(OP_NAMES))
132
+ p_call.add_argument("--params", help="JSON params, e.g. '{\"point\":[12.5,41.9]}'")
133
+ p_call.add_argument("--ws-port", dest="ws_port", type=int, default=8765)
134
+ p_call.add_argument("--wait", type=float, default=30.0,
135
+ help="seconds to wait for the hooked map to connect")
136
+ p_call.set_defaults(func=cmd_call)
137
+
138
+ return parser
139
+
140
+
141
+ def main(argv: Optional[list[str]] = None) -> int:
142
+ _force_utf8_output()
143
+ args = build_parser().parse_args(argv)
144
+ return args.func(args)
145
+
146
+
147
+ def _cli() -> None:
148
+ """Console-script entry point — propagate the exit code."""
149
+ raise SystemExit(main())
150
+
151
+
152
+ if __name__ == "__main__":
153
+ _cli()
map_mcp/coreops.py ADDED
@@ -0,0 +1,114 @@
1
+ """Core operations — the single implementation each frontend (MCP server + CLI) calls.
2
+
3
+ Every op issues one bridge call and returns the hook's structured result. The bridge is
4
+ injected, so a fake bridge makes this layer fully unit-testable without a browser. Input
5
+ validation lives here (geographic inputs are ``[lng, lat]``); the hook does the MapLibre work.
6
+ A bridge error (no map connected, timeout, hook error) propagates as ``BridgeError`` — the
7
+ frontends turn it into a clean tool/CLI error.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ # The canonical operation surface. The MCP tool set and the CLI command set both cover exactly
15
+ # these — the parity contract (asserted in the scenario test).
16
+ OP_NAMES = (
17
+ "get_viewport",
18
+ "query_rendered_features",
19
+ "get_features_at",
20
+ "click_at",
21
+ "read_popup",
22
+ "set_view",
23
+ "list_layers",
24
+ "set_layer_visibility",
25
+ "screenshot",
26
+ )
27
+
28
+
29
+ class CoreOps:
30
+ def __init__(self, bridge: Any) -> None:
31
+ self.bridge = bridge
32
+
33
+ def get_viewport(self) -> dict:
34
+ return self.bridge.call("get_viewport")
35
+
36
+ def query_rendered_features(
37
+ self,
38
+ point: Optional[list] = None,
39
+ bbox: Optional[list] = None,
40
+ layers: Optional[list] = None,
41
+ limit: Optional[int] = None,
42
+ ) -> dict:
43
+ params: dict = {}
44
+ if point is not None:
45
+ params["point"] = _point(point)
46
+ if bbox is not None:
47
+ params["bbox"] = _bbox(bbox)
48
+ if layers:
49
+ params["layers"] = list(layers)
50
+ if limit is not None:
51
+ params["limit"] = int(limit)
52
+ return self.bridge.call("query_rendered_features", params)
53
+
54
+ def get_features_at(self, point: list, layers: Optional[list] = None) -> dict:
55
+ params: dict = {"point": _point(point)}
56
+ if layers:
57
+ params["layers"] = list(layers)
58
+ return self.bridge.call("get_features_at", params)
59
+
60
+ def click_at(self, point: list, layers: Optional[list] = None) -> dict:
61
+ params: dict = {"point": _point(point)}
62
+ if layers:
63
+ params["layers"] = list(layers)
64
+ return self.bridge.call("click_at", params)
65
+
66
+ def read_popup(self) -> dict:
67
+ return self.bridge.call("read_popup")
68
+
69
+ def set_view(
70
+ self,
71
+ center: Optional[list] = None,
72
+ zoom: Optional[float] = None,
73
+ bbox: Optional[list] = None,
74
+ bearing: Optional[float] = None,
75
+ pitch: Optional[float] = None,
76
+ ) -> dict:
77
+ params: dict = {}
78
+ if center is not None:
79
+ params["center"] = _point(center)
80
+ if zoom is not None:
81
+ params["zoom"] = float(zoom)
82
+ if bbox is not None:
83
+ params["bbox"] = _bbox(bbox)
84
+ if bearing is not None:
85
+ params["bearing"] = float(bearing)
86
+ if pitch is not None:
87
+ params["pitch"] = float(pitch)
88
+ if not params:
89
+ raise ValueError("set_view needs at least one of center/zoom/bbox/bearing/pitch")
90
+ return self.bridge.call("set_view", params)
91
+
92
+ def list_layers(self) -> dict:
93
+ return self.bridge.call("list_layers")
94
+
95
+ def set_layer_visibility(self, layer: str, visible: bool) -> dict:
96
+ if not layer:
97
+ raise ValueError("set_layer_visibility needs a layer id")
98
+ return self.bridge.call("set_layer_visibility",
99
+ {"layer": layer, "visible": bool(visible)})
100
+
101
+ def screenshot(self) -> dict:
102
+ return self.bridge.call("screenshot")
103
+
104
+
105
+ def _point(p: Any) -> list:
106
+ if not (isinstance(p, (list, tuple)) and len(p) == 2):
107
+ raise ValueError(f"point must be [lng, lat], got {p!r}")
108
+ return [float(p[0]), float(p[1])]
109
+
110
+
111
+ def _bbox(b: Any) -> list:
112
+ if not (isinstance(b, (list, tuple)) and len(b) == 2):
113
+ raise ValueError(f"bbox must be [[lng,lat],[lng,lat]], got {b!r}")
114
+ return [_point(b[0]), _point(b[1])]
map_mcp/protocol.py ADDED
@@ -0,0 +1,46 @@
1
+ """Pure wire protocol for the bridge: request/response envelopes + id correlation.
2
+
3
+ No I/O — fully unit-testable. Both the live socket (``bridge.py``) and the in-browser hook
4
+ speak this. A request is ``{id, method, params}``; a reply is ``{id, ok, result}`` on success
5
+ or ``{id, ok=false, error}`` on failure.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+ import json
12
+ from typing import Any, Optional
13
+
14
+ _counter = itertools.count(1)
15
+
16
+
17
+ def next_id() -> int:
18
+ """Monotonic request id (process-local)."""
19
+ return next(_counter)
20
+
21
+
22
+ def make_request(method: str, params: Optional[dict] = None, *, id: Optional[int] = None) -> dict:
23
+ if not method:
24
+ raise ValueError("request needs a method")
25
+ return {
26
+ "id": id if id is not None else next_id(),
27
+ "method": method,
28
+ "params": params or {},
29
+ }
30
+
31
+
32
+ def make_response(id: Any, *, result: Any = None, error: Optional[str] = None) -> dict:
33
+ if error is not None:
34
+ return {"id": id, "ok": False, "error": error}
35
+ return {"id": id, "ok": True, "result": result}
36
+
37
+
38
+ def encode(msg: dict) -> str:
39
+ return json.dumps(msg)
40
+
41
+
42
+ def decode(text: str) -> dict:
43
+ msg = json.loads(text)
44
+ if not isinstance(msg, dict):
45
+ raise ValueError("envelope must be a JSON object")
46
+ return msg
map_mcp/server.py ADDED
@@ -0,0 +1,129 @@
1
+ """FastMCP server: exposes the core operations as MCP tools over stdio and HTTP/SSE.
2
+
3
+ Tool bodies are thin — they call the shared ``CoreOps`` (the same layer the CLI uses, so parity
4
+ holds). A no-map-connected / hook error / bad input is failed closed into a clean ``{"error":
5
+ ...}`` result rather than a raised exception, so an agent gets a message it can act on. HTTP/SSE
6
+ refuses non-loopback binds (the bridge is local-only).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable, Optional
12
+
13
+ from fastmcp import FastMCP
14
+
15
+ from .bridge import BridgeError
16
+ from .coreops import OP_NAMES, CoreOps
17
+
18
+ TOOL_NAMES = OP_NAMES
19
+ VALID_TRANSPORTS = ("stdio", "http", "sse")
20
+
21
+
22
+ def _guard(fn: Callable) -> Callable:
23
+ """Fail closed: turn a no-connection/hook/input error into a clean error result.
24
+
25
+ Matches the CLI's run_op catch set (BridgeError, ValueError, TypeError) so the two
26
+ frontends fail closed identically. The bridge converts transport faults (closed loop,
27
+ dropped socket) into BridgeError at the source, so nothing else should escape here."""
28
+ def wrapped(*args, **kwargs):
29
+ try:
30
+ return fn(*args, **kwargs)
31
+ except (BridgeError, ValueError, TypeError) as e:
32
+ return {"error": str(e)}
33
+ return wrapped
34
+
35
+
36
+ def core_tool_handlers(ops: CoreOps) -> dict[str, Callable]:
37
+ """The tool implementations bound to one CoreOps — directly unit-testable."""
38
+ return {
39
+ "get_viewport": _guard(lambda: ops.get_viewport()),
40
+ "query_rendered_features": _guard(
41
+ lambda point=None, bbox=None, layers=None, limit=None:
42
+ ops.query_rendered_features(point, bbox, layers, limit)),
43
+ "get_features_at": _guard(lambda point, layers=None: ops.get_features_at(point, layers)),
44
+ "click_at": _guard(lambda point, layers=None: ops.click_at(point, layers)),
45
+ "read_popup": _guard(lambda: ops.read_popup()),
46
+ "set_view": _guard(lambda center=None, zoom=None, bbox=None, bearing=None, pitch=None:
47
+ ops.set_view(center, zoom, bbox, bearing, pitch)),
48
+ "list_layers": _guard(lambda: ops.list_layers()),
49
+ "set_layer_visibility": _guard(lambda layer, visible: ops.set_layer_visibility(layer, visible)),
50
+ "screenshot": _guard(lambda: ops.screenshot()),
51
+ }
52
+
53
+
54
+ def build_server(bridge: Any, app_name: str = "map-mcp"):
55
+ """Construct a FastMCP server exposing the core tools, bound to one bridge."""
56
+ mcp = FastMCP(app_name)
57
+ H = core_tool_handlers(CoreOps(bridge))
58
+
59
+ @mcp.tool
60
+ def get_viewport() -> dict:
61
+ """Current viewport: center [lng,lat], zoom, bearing, pitch, bounds."""
62
+ return H["get_viewport"]()
63
+
64
+ @mcp.tool
65
+ def query_rendered_features(point: Optional[list] = None, bbox: Optional[list] = None,
66
+ layers: Optional[list] = None, limit: Optional[int] = None) -> dict:
67
+ """Features currently rendered, optionally at a [lng,lat] point or within a [[lng,lat],[lng,lat]] bbox."""
68
+ return H["query_rendered_features"](point=point, bbox=bbox, layers=layers, limit=limit)
69
+
70
+ @mcp.tool
71
+ def get_features_at(point: list, layers: Optional[list] = None) -> dict:
72
+ """Features rendered at a [lng,lat] point."""
73
+ return H["get_features_at"](point, layers)
74
+
75
+ @mcp.tool
76
+ def click_at(point: list, layers: Optional[list] = None) -> dict:
77
+ """Fire the map's click at a [lng,lat] point (runs app popup handlers) and return features + popup."""
78
+ return H["click_at"](point, layers)
79
+
80
+ @mcp.tool
81
+ def read_popup() -> dict:
82
+ """Text of any open map popup(s)."""
83
+ return H["read_popup"]()
84
+
85
+ @mcp.tool
86
+ def set_view(center: Optional[list] = None, zoom: Optional[float] = None,
87
+ bbox: Optional[list] = None, bearing: Optional[float] = None,
88
+ pitch: Optional[float] = None) -> dict:
89
+ """Move the map: center+zoom (and bearing/pitch), or fit a bbox. Returns the new viewport."""
90
+ return H["set_view"](center=center, zoom=zoom, bbox=bbox, bearing=bearing, pitch=pitch)
91
+
92
+ @mcp.tool
93
+ def list_layers() -> dict:
94
+ """The style's layers with id, type, source, and visibility."""
95
+ return H["list_layers"]()
96
+
97
+ @mcp.tool
98
+ def set_layer_visibility(layer: str, visible: bool) -> dict:
99
+ """Show or hide a layer by id."""
100
+ return H["set_layer_visibility"](layer, visible)
101
+
102
+ @mcp.tool
103
+ def screenshot() -> dict:
104
+ """A PNG data URL of the current map (needs preserveDrawingBuffer:true on the map)."""
105
+ return H["screenshot"]()
106
+
107
+ return mcp
108
+
109
+
110
+ def validate_transport(transport: str) -> str:
111
+ if transport not in VALID_TRANSPORTS:
112
+ raise ValueError(f"transport must be one of {VALID_TRANSPORTS}, got {transport!r}")
113
+ return transport
114
+
115
+
116
+ def run_server(bridge: Any, transport: str = "stdio", host: str = "127.0.0.1",
117
+ port: int = 8000) -> None:
118
+ """Build and run the MCP server (blocking). The bridge's ws server is started separately."""
119
+ validate_transport(transport)
120
+ if transport in ("http", "sse") and host not in ("127.0.0.1", "::1", "localhost"):
121
+ raise ValueError(
122
+ f"Refusing to serve {transport} on non-loopback host {host!r}: the map bridge is "
123
+ "local-only. Bind 127.0.0.1, or use stdio."
124
+ )
125
+ mcp = build_server(bridge)
126
+ if transport == "stdio":
127
+ mcp.run()
128
+ else:
129
+ mcp.run(transport=transport, host=host, port=port)
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: map-mcp
3
+ Version: 0.1.0
4
+ Summary: Drive and perceive an existing live MapLibre GL map from an AI agent (MCP) or a human CLI — no test code, no browser automation.
5
+ Project-URL: Homepage, https://github.com/dkedar7/map-mcp
6
+ Project-URL: Source, https://github.com/dkedar7/map-mcp
7
+ Project-URL: Bug Tracker, https://github.com/dkedar7/map-mcp/issues
8
+ Author: Kedar Dabhadkar
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Kedar Dabhadkar
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: agent,geospatial,llm,map,maplibre,mcp,model-context-protocol
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Scientific/Engineering :: GIS
39
+ Classifier: Topic :: Software Development :: Libraries
40
+ Requires-Python: >=3.10
41
+ Requires-Dist: fastmcp<4,>=3
42
+ Requires-Dist: websockets>=12
43
+ Provides-Extra: dev
44
+ Requires-Dist: pytest>=7; extra == 'dev'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # map-mcp
48
+
49
+ Drive and perceive an **existing, live MapLibre GL map** from an AI agent (over MCP) or a human
50
+ CLI — query the rendered features, read the viewport, click and read popups, navigate, toggle
51
+ layers. The agent and the CLI act on the **same map a person is looking at**, with parity by
52
+ construction.
53
+
54
+ It does **not** generate maps. Other geo-MCP servers (gis-mcp, Mapbox, CARTO) create maps or
55
+ call GIS operations; map-mcp reaches into a map that's *already on screen*. Think of it as the
56
+ agent-native counterpart to [MapGrab](https://github.com/maxlapides/mapgrab): same live-map
57
+ access, but conversational over MCP instead of written as test code.
58
+
59
+ > **Status:** v1, MapLibre GL only. Cooperation-required (your app adds a one-line hook). A
60
+ > no-cooperation path and other map libraries are future work.
61
+
62
+ ## Install
63
+
64
+ ```
65
+ uvx map-mcp --help # or: pip install map-mcp
66
+ ```
67
+
68
+ ## Quickstart
69
+
70
+ 1. **Start the bridge + MCP server.** It prints a WebSocket URL and a per-session token.
71
+ ```
72
+ map-mcp serve
73
+ ```
74
+ 2. **Add the hook to your MapLibre page** (`map` is your existing `maplibregl.Map`):
75
+ ```html
76
+ <script src="map-mcp-hook.js"></script>
77
+ <script>
78
+ mapMcp.register(map, { url: "ws://127.0.0.1:8765", token: "PASTE_TOKEN" });
79
+ </script>
80
+ ```
81
+ 3. **Point your agent at the MCP server** (stdio by default; `--transport http` for HTTP/SSE).
82
+ Or drive it yourself from the terminal:
83
+ ```
84
+ map-mcp call get_viewport
85
+ map-mcp call query_rendered_features --params '{"point":[12.5,41.9]}'
86
+ ```
87
+
88
+ There's a runnable example in [`examples/sample_app/`](examples/sample_app/).
89
+
90
+ ## Tools (the operation surface)
91
+
92
+ The agent's MCP tools and the CLI's `call` operations are exactly the same set:
93
+
94
+ | Operation | What it does |
95
+ |---|---|
96
+ | `get_viewport` | center `[lng,lat]`, zoom, bearing, pitch, bounds |
97
+ | `query_rendered_features` | features currently rendered (optionally at a point or within a bbox) |
98
+ | `get_features_at` | features rendered at a `[lng,lat]` point |
99
+ | `click_at` | fire the map's click at a point (runs your popup handlers), return features + popup |
100
+ | `read_popup` | text of any open popup(s) |
101
+ | `set_view` | center+zoom (and bearing/pitch), or fit a bbox |
102
+ | `list_layers` | the style's layers + visibility |
103
+ | `set_layer_visibility` | show/hide a layer |
104
+ | `screenshot` | a PNG data URL of the current map* |
105
+
106
+ Perception returns **structured feature properties** (GeoJSON-shaped) — agents reason over
107
+ properties, not pixels. `screenshot` is optional.
108
+
109
+ \* needs the map created with `preserveDrawingBuffer: true` (see [`hook/snippet.md`](hook/snippet.md)).
110
+
111
+ ## How it works
112
+
113
+ The hook connects *out* to a loopback WebSocket the `map-mcp` process runs. The MCP tools and
114
+ the CLI are thin frontends over one shared core-operations layer, so any operation one can do,
115
+ the other can too.
116
+
117
+ **Security model (local-only).** The bridge binds `127.0.0.1` only, so nothing off your machine
118
+ can reach it. Browsers do *not* apply same-origin policy to WebSocket connections, so the
119
+ **per-session token is the security boundary**: only a page that presents it can drive your map.
120
+ Treat the token like a secret — the convenience `?token=` pattern in the example leaks it via
121
+ browser history and server logs, so for anything sensitive paste the token into the page rather
122
+ than the URL. A hardened Origin allowlist is future work.
123
+
124
+ ## Scope (v1)
125
+
126
+ - **In:** MapLibre GL; the operations above; stdio + HTTP/SSE; a human CLI with parity.
127
+ - **Out:** generating maps; a hosted service; non-map visualizations; other map libraries
128
+ (Leaflet/deck.gl) and a no-cooperation (Playwright) path are future work.
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,11 @@
1
+ map_mcp/__init__.py,sha256=ibEmgPip9FsO2DtyPz6vDwy-wGIiiovMgrrN_Z-7mdg,510
2
+ map_mcp/bridge.py,sha256=073eNQ4ucVAeNgMJapok8VJlWEBZvI8z8DZXMEGkjk8,8355
3
+ map_mcp/cli.py,sha256=OBSZrSGGB5w-hKkpCldHU3isPE-xR7AO4gO6IIonsOQ,5618
4
+ map_mcp/coreops.py,sha256=7Cl7sm92FqjrnINDMN0OI9FQFkXrs46zSknVYuwZQ7Q,3938
5
+ map_mcp/protocol.py,sha256=2hNGFUXuYPWvn9szRQCcszDVOq1_e1Ei-R1f4oAhykw,1293
6
+ map_mcp/server.py,sha256=_27L-DxCFvzkZYf7RnHDyGrxfYsRcj3B_AZHNEifGPA,5493
7
+ map_mcp-0.1.0.dist-info/METADATA,sha256=GJQvG7TcQjO5UGZGiY8I155hz7qz2QoBCh9IjcFW7gI,5980
8
+ map_mcp-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ map_mcp-0.1.0.dist-info/entry_points.txt,sha256=uviG52jhZepoabiz5QZLdz3kgugXzmEyThkNp0MJklY,45
10
+ map_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=68VkOZUTE_Q_g8vf9largzAs5-Z9lXBNy3EMH-z_Gw4,1072
11
+ map_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
+ map-mcp = map_mcp.cli:_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kedar Dabhadkar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.